]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-16221 Add select variants (creatable and async) and replace SelectLegacy
authorJeremy Davis <jeremy.davis@sonarsource.com>
Fri, 1 Apr 2022 13:48:02 +0000 (15:48 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 5 Apr 2022 20:03:16 +0000 (20:03 +0000)
25 files changed:
server/sonar-web/src/main/js/app/components/extensions/exposeLibraries.ts
server/sonar-web/src/main/js/apps/create/project/GitHubProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHubProjectCreateRenderer-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/GitHubProjectCreateRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/BulkChangeModal-test.tsx.snap
server/sonar-web/src/main/js/apps/projectBaseline/components/BaselineSettingReferenceBranch.tsx
server/sonar-web/src/main/js/apps/projectBaseline/components/BranchBaselineSettingModal.tsx
server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BaselineSettingReferenceBranch-test.tsx
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BaselineSettingReferenceBranch-test.tsx.snap
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/BranchBaselineSettingModal-test.tsx.snap
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/__snapshots__/ProjectBaselineSelector-test.tsx.snap
server/sonar-web/src/main/js/components/controls/SearchSelect.tsx [deleted file]
server/sonar-web/src/main/js/components/controls/Select.tsx
server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/Select-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/Select-test.tsx.snap
server/sonar-web/src/main/js/helpers/mocks/react-select.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index ab9847c117b052f23afefe6d21b00b848d36d77e..429cb1ae374490cac342fbaefb2a1729d98548ff 100644 (file)
@@ -41,8 +41,7 @@ import Radio from '../../../components/controls/Radio';
 import RadioToggle from '../../../components/controls/RadioToggle';
 import ReloadButton from '../../../components/controls/ReloadButton';
 import SearchBox from '../../../components/controls/SearchBox';
-import SearchSelect from '../../../components/controls/SearchSelect';
-import Select from '../../../components/controls/Select';
+import Select, { SearchSelect } from '../../../components/controls/Select';
 import SelectLegacy from '../../../components/controls/SelectLegacy';
 import SelectList, { SelectListFilter } from '../../../components/controls/SelectList';
 import SimpleModal from '../../../components/controls/SimpleModal';
index 7b26bf5d4b4aa9ae1289885dd5cb16804f7cb965..ffde9cf777e12a4cdf55dd4fb25efe80828e51bc 100644 (file)
@@ -25,7 +25,7 @@ import { Button } from '../../../components/controls/buttons';
 import ListFooter from '../../../components/controls/ListFooter';
 import Radio from '../../../components/controls/Radio';
 import SearchBox from '../../../components/controls/SearchBox';
-import SearchSelect from '../../../components/controls/SearchSelect';
+import Select, { BasicSelectOption } from '../../../components/controls/Select';
 import CheckIcon from '../../../components/icons/CheckIcon';
 import QualifierIcon from '../../../components/icons/QualifierIcon';
 import { Alert } from '../../../components/ui/Alert';
@@ -62,9 +62,6 @@ function orgToOption({ key, name }: GithubOrganization) {
   return { value: key, label: name };
 }
 
-const handleSearch = (organizations: GithubOrganization[]) => (q: string) =>
-  Promise.resolve(organizations.filter(o => !q || o.name.includes(q)).map(orgToOption));
-
 function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
   const {
     importing,
@@ -240,12 +237,11 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
           <div className="form-field">
             <label>{translate('onboarding.create_project.github.choose_organization')}</label>
             {organizations.length > 0 ? (
-              <SearchSelect
-                defaultOptions={organizations.map(orgToOption)}
-                onSearch={handleSearch(organizations)}
-                minimumQueryLength={0}
-                onSelect={({ value }) => props.onSelectOrganization(value)}
-                value={selectedOrganization && orgToOption(selectedOrganization)}
+              <Select
+                className="input-super-large"
+                options={organizations.map(orgToOption)}
+                onChange={({ value }: BasicSelectOption) => props.onSelectOrganization(value)}
+                value={selectedOrganization ? orgToOption(selectedOrganization) : null}
               />
             ) : (
               !loadingOrganizations && (
index 2f37b51928b1fbf0cdc5a55fb86c198a90c4df64..380170f9a013bdcebf02ec8d1f0fe84a02844cf9 100644 (file)
@@ -21,7 +21,7 @@ import { shallow } from 'enzyme';
 import * as React from 'react';
 import Radio from '../../../../components/controls/Radio';
 import SearchBox from '../../../../components/controls/SearchBox';
-import SearchSelect from '../../../../components/controls/SearchSelect';
+import Select from '../../../../components/controls/Select';
 import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations';
 import { GithubOrganization } from '../../../../types/alm-integration';
 import GitHubProjectCreateRenderer, {
@@ -85,7 +85,7 @@ describe('callback', () => {
 
   it('should be called when org is selected', () => {
     const value = 'o1';
-    wrapper.find(SearchSelect).simulate('select', { value });
+    wrapper.find(Select).simulate('change', { value });
     expect(onSelectOrganization).toBeCalledWith(value);
   });
 
index ea876a7498e19bb792febbfd06b3bd25a051ed09..56300a11343b01fb17bf5c05d8556a0641bf4d94 100644 (file)
@@ -172,8 +172,10 @@ exports[`should render correctly: no repositories 1`] = `
       <label>
         onboarding.create_project.github.choose_organization
       </label>
-      <SearchSelect
-        defaultOptions={
+      <Select
+        className="input-super-large"
+        onChange={[Function]}
+        options={
           Array [
             Object {
               "label": "org1",
@@ -185,9 +187,6 @@ exports[`should render correctly: no repositories 1`] = `
             },
           ]
         }
-        minimumQueryLength={0}
-        onSearch={[Function]}
-        onSelect={[Function]}
         value={
           Object {
             "label": "org2",
@@ -226,8 +225,10 @@ exports[`should render correctly: organizations 1`] = `
       <label>
         onboarding.create_project.github.choose_organization
       </label>
-      <SearchSelect
-        defaultOptions={
+      <Select
+        className="input-super-large"
+        onChange={[Function]}
+        options={
           Array [
             Object {
               "label": "org1",
@@ -239,9 +240,7 @@ exports[`should render correctly: organizations 1`] = `
             },
           ]
         }
-        minimumQueryLength={0}
-        onSearch={[Function]}
-        onSelect={[Function]}
+        value={null}
       />
     </div>
   </DeferredSpinner>
@@ -291,8 +290,10 @@ exports[`should render correctly: repositories 1`] = `
       <label>
         onboarding.create_project.github.choose_organization
       </label>
-      <SearchSelect
-        defaultOptions={
+      <Select
+        className="input-super-large"
+        onChange={[Function]}
+        options={
           Array [
             Object {
               "label": "org1",
@@ -304,9 +305,6 @@ exports[`should render correctly: repositories 1`] = `
             },
           ]
         }
-        minimumQueryLength={0}
-        onSearch={[Function]}
-        onSelect={[Function]}
         value={
           Object {
             "label": "org2",
diff --git a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
new file mode 100644 (file)
index 0000000..987c4cb
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 { debounce } from 'lodash';
+import * as React from 'react';
+import { components, OptionProps, SingleValueProps } from 'react-select';
+import { BasicSelectOption, SearchSelect } from '../../../components/controls/Select';
+import Avatar from '../../../components/ui/Avatar';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Issue } from '../../../types/types';
+import { CurrentUser, isLoggedIn, isUserActive } from '../../../types/users';
+import { searchAssignees } from '../utils';
+
+const DEBOUNCE_DELAY = 250;
+// exported for test
+export const MIN_QUERY_LENGTH = 2;
+
+export interface AssigneeOption extends BasicSelectOption {
+  avatar?: string;
+  email?: string;
+  label: string;
+  value: string;
+}
+
+export interface AssigneeSelectProps {
+  currentUser: CurrentUser;
+  issues: Issue[];
+  onAssigneeSelect: (assignee: AssigneeOption) => void;
+}
+
+export default class AssigneeSelect extends React.Component<AssigneeSelectProps> {
+  constructor(props: AssigneeSelectProps) {
+    super(props);
+
+    this.handleAssigneeSearch = debounce(this.handleAssigneeSearch, DEBOUNCE_DELAY);
+  }
+
+  getDefaultAssignee = () => {
+    const { currentUser, issues } = this.props;
+    const options = [];
+
+    if (isLoggedIn(currentUser)) {
+      const canBeAssignedToMe =
+        issues.filter(issue => issue.assignee !== currentUser.login).length > 0;
+      if (canBeAssignedToMe) {
+        options.push({
+          avatar: currentUser.avatar,
+          label: currentUser.name,
+          value: currentUser.login
+        });
+      }
+    }
+
+    const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0;
+    if (canBeUnassigned) {
+      options.push({ label: translate('unassigned'), value: '' });
+    }
+
+    return options;
+  };
+
+  handleAssigneeSearch = (query: string, resolve: (options: AssigneeOption[]) => void) => {
+    if (query.length < MIN_QUERY_LENGTH) {
+      resolve([]);
+      return;
+    }
+
+    searchAssignees(query)
+      .then(({ results }) =>
+        results.map(r => {
+          const userInfo = r.name || r.login;
+
+          return {
+            avatar: r.avatar,
+            label: isUserActive(r) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
+            value: r.login
+          };
+        })
+      )
+      .then(resolve)
+      .catch(() => resolve([]));
+  };
+
+  renderAssignee = (option: AssigneeOption) => {
+    return (
+      <div className="display-flex-center">
+        {option.avatar !== undefined && (
+          <Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
+        )}
+        {option.label}
+      </div>
+    );
+  };
+
+  renderAssigneeOption = (props: OptionProps<AssigneeOption, false>) => (
+    <components.Option {...props}>{this.renderAssignee(props.data)}</components.Option>
+  );
+
+  renderSingleAssignee = (props: SingleValueProps<AssigneeOption>) => (
+    <components.SingleValue {...props}>{this.renderAssignee(props.data)}</components.SingleValue>
+  );
+
+  render() {
+    return (
+      <SearchSelect
+        className="input-super-large"
+        components={{
+          Option: this.renderAssigneeOption,
+          SingleValue: this.renderSingleAssignee
+        }}
+        isClearable={true}
+        defaultOptions={this.getDefaultAssignee()}
+        loadOptions={this.handleAssigneeSearch}
+        onChange={this.props.onAssigneeSelect}
+        noOptionsMessage={({ inputValue }) =>
+          inputValue.length < MIN_QUERY_LENGTH
+            ? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
+            : translate('select2.noMatches')
+        }
+      />
+    );
+  }
+}
index 7754785e2afb3e9ddbe3253af613b0e362a7ca21..78e5b564bee5b11076bd0b5fc3fec302f3a77d39 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { pickBy, sortBy } from 'lodash';
+import { debounce, pickBy, sortBy } from 'lodash';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { components, OptionProps, SingleValueProps } from 'react-select';
@@ -28,27 +28,24 @@ import Checkbox from '../../../components/controls/Checkbox';
 import HelpTooltip from '../../../components/controls/HelpTooltip';
 import Modal from '../../../components/controls/Modal';
 import Radio from '../../../components/controls/Radio';
-import SearchSelect from '../../../components/controls/SearchSelect';
-import Select, { BasicSelectOption } from '../../../components/controls/Select';
+import Select, {
+  BasicSelectOption,
+  CreatableSelect,
+  SearchSelect
+} from '../../../components/controls/Select';
 import Tooltip from '../../../components/controls/Tooltip';
 import IssueTypeIcon from '../../../components/icons/IssueTypeIcon';
 import SeverityHelper from '../../../components/shared/SeverityHelper';
 import { Alert } from '../../../components/ui/Alert';
-import Avatar from '../../../components/ui/Avatar';
 import { throwGlobalError } from '../../../helpers/error';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Component, Dict, Issue, IssueType, Paging } from '../../../types/types';
-import { CurrentUser, isLoggedIn, isUserActive } from '../../../types/users';
-import { searchAssignees } from '../utils';
+import { CurrentUser } from '../../../types/users';
+import AssigneeSelect, { AssigneeOption } from './AssigneeSelect';
 
-interface AssigneeOption {
-  avatar?: string;
-  email?: string;
-  label: string;
-  value: string;
-}
+const DEBOUNCE_DELAY = 250;
 
-interface TagOption {
+interface TagOption extends BasicSelectOption {
   label: string;
   value: string;
 }
@@ -82,12 +79,6 @@ interface State extends FormFields {
   submitting: boolean;
 }
 
-type AssigneeSelectType = new () => SearchSelect<AssigneeOption>;
-const AssigneeSelect = SearchSelect as AssigneeSelectType;
-
-type TagSelectType = new () => SearchSelect<TagOption>;
-const TagSelect = SearchSelect as TagSelectType;
-
 export const MAX_PAGE_SIZE = 500;
 
 export default class BulkChangeModal extends React.PureComponent<Props, State> {
@@ -96,6 +87,8 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
   constructor(props: Props) {
     super(props);
     this.state = { initialTags: [], issues: [], loading: true, submitting: false };
+
+    this.handleTagsSearch = debounce(this.handleTagsSearch, DEBOUNCE_DELAY);
   }
 
   componentDidMount() {
@@ -128,58 +121,18 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
   };
 
-  getDefaultAssignee = () => {
-    const { currentUser } = this.props;
-    const { issues } = this.state;
-    const options = [];
-
-    if (isLoggedIn(currentUser)) {
-      const canBeAssignedToMe =
-        issues.filter(issue => issue.assignee !== currentUser.login).length > 0;
-      if (canBeAssignedToMe) {
-        options.push({
-          avatar: currentUser.avatar,
-          label: currentUser.name,
-          value: currentUser.login
-        });
-      }
-    }
-
-    const canBeUnassigned = issues.filter(issue => issue.assignee).length > 0;
-    if (canBeUnassigned) {
-      options.push({ label: translate('unassigned'), value: '' });
-    }
-
-    return options;
-  };
-
-  handleAssigneeSearch = (query: string) => {
-    return searchAssignees(query).then(({ results }) =>
-      results.map(r => {
-        const userInfo = r.name || r.login;
-
-        return {
-          avatar: r.avatar,
-          label: isUserActive(r) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
-          value: r.login
-        };
-      })
-    );
-  };
-
   handleAssigneeSelect = (assignee: AssigneeOption) => {
     this.setState({ assignee });
   };
 
-  handleTagsSearch = (query: string) => {
-    return searchIssueTags({ q: query }).then(tags =>
-      tags.map(tag => ({ label: tag, value: tag }))
-    );
+  handleTagsSearch = (query: string, resolve: (option: TagOption[]) => void) => {
+    searchIssueTags({ q: query })
+      .then(tags => tags.map(tag => ({ label: tag, value: tag })))
+      .then(resolve)
+      .catch(() => resolve([]));
   };
 
-  handleTagsSelect = (field: 'addTags' | 'removeTags') => (
-    options: Array<{ label: string; value: string }>
-  ) => {
+  handleTagsSelect = (field: 'addTags' | 'removeTags') => (options: TagOption[]) => {
     this.setState<keyof FormFields>({ [field]: options });
   };
 
@@ -306,18 +259,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     </div>
   );
 
-  renderAssigneeOption = (option: AssigneeOption) => {
-    return (
-      <span>
-        {option.avatar !== undefined && (
-          <Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
-        )}
-        {option.label}
-      </span>
-    );
-  };
-
   renderAssigneeField = () => {
+    const { currentUser } = this.props;
+    const { issues } = this.state;
     const affected = this.state.issues.filter(hasAction('assign')).length;
 
     if (affected === 0) {
@@ -326,14 +270,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
 
     const input = (
       <AssigneeSelect
-        className="input-super-large"
-        clearable={true}
-        defaultOptions={this.getDefaultAssignee()}
-        onSearch={this.handleAssigneeSearch}
-        onSelect={this.handleAssigneeSelect}
-        renderOption={this.renderAssigneeOption}
-        resetOnBlur={false}
-        value={this.state.assignee}
+        currentUser={currentUser}
+        issues={issues}
+        onAssigneeSelect={this.handleAssigneeSelect}
       />
     );
 
@@ -419,10 +358,6 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     return this.renderField('severity', 'issue.set_severity', affected, input);
   };
 
-  renderTagOption = (option: TagOption) => {
-    return <span>{option.label}</span>;
-  };
-
   renderTagsField = (field: 'addTags' | 'removeTags', label: string, allowCreate: boolean) => {
     const { initialTags } = this.state;
     const affected = this.state.issues.filter(hasAction('set_tags')).length;
@@ -431,21 +366,19 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
       return null;
     }
 
-    const input = (
-      <TagSelect
-        canCreate={allowCreate}
-        className="input-super-large"
-        clearable={true}
-        defaultOptions={this.state.initialTags}
-        minimumQueryLength={0}
-        multi={true}
-        onMultiSelect={this.handleTagsSelect(field)}
-        onSearch={this.handleTagsSearch}
-        promptTextCreator={promptCreateTag}
-        renderOption={this.renderTagOption}
-        resetOnBlur={false}
-        value={this.state[field]}
-      />
+    const props = {
+      className: 'input-super-large',
+      isClearable: true,
+      defaultOptions: this.state.initialTags,
+      isMulti: true,
+      onChange: this.handleTagsSelect(field),
+      loadOptions: this.handleTagsSearch
+    };
+
+    const input = allowCreate ? (
+      <CreatableSelect {...props} formatCreateLabel={createTagPrompt} />
+    ) : (
+      <SearchSelect {...props} />
     );
 
     return this.renderField(field, label, affected, input);
@@ -580,6 +513,6 @@ function hasAction(action: string) {
   return (issue: Issue) => issue.actions && issue.actions.includes(action);
 }
 
-function promptCreateTag(label: string) {
-  return `+ ${label}`;
+function createTagPrompt(label: string) {
+  return translateWithParameters('issue.create_tag_x', label);
 }
diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx
new file mode 100644 (file)
index 0000000..211010e
--- /dev/null
@@ -0,0 +1,149 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 { Props as ReactSelectAsyncProps } from 'react-select/async';
+import { SearchSelect } from '../../../../components/controls/Select';
+import Avatar from '../../../../components/ui/Avatar';
+import { mockCurrentUser, mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks';
+import { searchAssignees } from '../../utils';
+import AssigneeSelect, {
+  AssigneeOption,
+  AssigneeSelectProps,
+  MIN_QUERY_LENGTH
+} from '../AssigneeSelect';
+
+jest.mock('../../utils', () => ({
+  searchAssignees: jest.fn().mockResolvedValue({
+    results: [
+      {
+        active: true,
+        avatar: '##avatar1',
+        login: 'toto@toto',
+        name: 'toto'
+      },
+      {
+        active: false,
+        avatar: '##avatar2',
+        login: 'tata@tata',
+        name: 'tata'
+      },
+      {
+        active: true,
+        avatar: '##avatar3',
+        login: 'titi@titi'
+      }
+    ]
+  })
+}));
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('default');
+  expect(shallowRender({ currentUser: mockLoggedInUser(), issues: [mockIssue()] })).toMatchSnapshot(
+    'logged in & assignable issues'
+  );
+  expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot(
+    'logged in & no assignable issues'
+  );
+  expect(shallowRender({ issues: [mockIssue(false, { assignee: 'someone' })] })).toMatchSnapshot(
+    'unassignable issues'
+  );
+});
+
+it('should render options correctly', () => {
+  const wrapper = shallowRender();
+
+  expect(
+    shallow(
+      wrapper.instance().renderAssignee({
+        avatar: '##avatar1',
+        value: 'toto@toto',
+        label: 'toto'
+      })
+    )
+      .find(Avatar)
+      .exists()
+  ).toBe(true);
+
+  expect(
+    shallow(
+      wrapper.instance().renderAssignee({
+        value: 'toto@toto',
+        label: 'toto'
+      })
+    )
+      .find(Avatar)
+      .exists()
+  ).toBe(false);
+});
+
+it('should render noOptionsMessage correctly', () => {
+  const wrapper = shallowRender();
+  expect(
+    wrapper.find<ReactSelectAsyncProps<AssigneeOption, false>>(SearchSelect).props()
+      .noOptionsMessage!({ inputValue: 'a' })
+  ).toBe(`select2.tooShort.${MIN_QUERY_LENGTH}`);
+
+  expect(
+    wrapper.find<ReactSelectAsyncProps<AssigneeOption, false>>(SearchSelect).props()
+      .noOptionsMessage!({ inputValue: 'droids' })
+  ).toBe('select2.noMatches');
+});
+
+it('should handle assignee search', async () => {
+  const onAssigneeSelect = jest.fn();
+  const wrapper = shallowRender({ onAssigneeSelect });
+
+  wrapper.instance().handleAssigneeSearch('a', jest.fn());
+  expect(searchAssignees).not.toBeCalled();
+
+  const result = await new Promise((resolve: (opts: AssigneeOption[]) => void) => {
+    wrapper.instance().handleAssigneeSearch('someone', resolve);
+  });
+
+  expect(result).toEqual([
+    {
+      avatar: '##avatar1',
+      value: 'toto@toto',
+      label: 'toto'
+    },
+    {
+      avatar: '##avatar2',
+      value: 'tata@tata',
+      label: 'user.x_deleted.tata'
+    },
+    {
+      avatar: '##avatar3',
+      value: 'titi@titi',
+      label: 'user.x_deleted.titi@titi'
+    }
+  ]);
+});
+
+function shallowRender(overrides: Partial<AssigneeSelectProps> = {}) {
+  return shallow<AssigneeSelect>(
+    <AssigneeSelect
+      currentUser={mockCurrentUser()}
+      issues={[]}
+      onAssigneeSelect={jest.fn()}
+      {...overrides}
+    />
+  );
+}
index 6e91c7258cb2ff597edf7565bb0203dda7e09b71..19c592dbb47d2778897532eca567270607d941c6 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { Props as ReactSelectProps } from 'react-select';
 import { SelectComponentsProps } from 'react-select/src/Select';
 import { searchIssueTags } from '../../../../api/issues';
 import { SubmitButton } from '../../../../components/controls/buttons';
-import Select from '../../../../components/controls/Select';
+import Select, { CreatableSelect, SearchSelect } from '../../../../components/controls/Select';
 import { mockIssue } from '../../../../helpers/testMocks';
 import { change, waitAndUpdate } from '../../../../helpers/testUtils';
 import { Issue } from '../../../../types/types';
@@ -32,30 +33,6 @@ jest.mock('../../../../api/issues', () => ({
   searchIssueTags: jest.fn().mockResolvedValue([undefined, []])
 }));
 
-jest.mock('../../utils', () => ({
-  searchAssignees: jest.fn().mockResolvedValue({
-    results: [
-      {
-        active: true,
-        avatar: '##toto',
-        login: 'toto@toto',
-        name: 'toto'
-      },
-      {
-        active: false,
-        avatar: '##toto',
-        login: 'login@login',
-        name: 'toto'
-      },
-      {
-        active: true,
-        avatar: '##toto',
-        login: 'login@login'
-      }
-    ]
-  })
-}));
-
 it('should display error message when no issues available', async () => {
   const wrapper = getWrapper([]);
   await waitAndUpdate(wrapper);
@@ -80,20 +57,11 @@ it('should display warning when too many issues are passed', async () => {
   expect(wrapper.find('Alert')).toMatchSnapshot();
 });
 
-it('should properly handle the search for assignee', async () => {
-  const issues: Issue[] = [];
-  for (let i = MAX_PAGE_SIZE + 1; i > 0; i--) {
-    issues.push(mockIssue());
-  }
-
-  const wrapper = getWrapper(issues);
-  const result = await wrapper.instance().handleAssigneeSearch('toto');
-  expect(result).toMatchSnapshot();
-});
-
 it('should properly handle the search for tags', async () => {
   const wrapper = getWrapper([]);
-  await wrapper.instance().handleTagsSearch('query');
+  await new Promise(resolve => {
+    wrapper.instance().handleTagsSearch('query', resolve);
+  });
   expect(searchIssueTags).toBeCalled();
 });
 
@@ -110,27 +78,30 @@ it.each([
   expect(SingleValue({ data: { label: 'label', value: 'value' } })).toMatchSnapshot('SingleValue');
 });
 
+it('should render tags correctly', async () => {
+  const wrapper = getWrapper([mockIssue(false, { actions: ['set_tags'] })]);
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.find(CreatableSelect).exists()).toBe(true);
+  expect(wrapper.find(SearchSelect).exists()).toBe(true);
+});
+
 it('should disable the submit button unless some change is configured', async () => {
   const wrapper = getWrapper([mockIssue(false, { actions: ['set_severity', 'comment'] })]);
   await waitAndUpdate(wrapper);
 
-  return new Promise<void>((resolve, reject) => {
+  return new Promise<void>(resolve => {
     expect(wrapper.find(SubmitButton).props().disabled).toBe(true);
 
     // Setting a comment is not sufficient; some other change must occur.
     change(wrapper.find('#comment'), 'Some comment');
     expect(wrapper.find(SubmitButton).props().disabled).toBe(true);
 
-    const { onChange } = wrapper
-      .find(Select)
+    wrapper
+      .find<ReactSelectProps>(Select)
       .at(0)
-      .props();
-    if (!onChange) {
-      reject();
-      return;
-    }
+      .simulate('change', { value: 'foo' });
 
-    onChange({ value: 'foo' });
     expect(wrapper.find(SubmitButton).props().disabled).toBe(false);
     resolve();
   });
diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/components/__tests__/__snapshots__/AssigneeSelect-test.tsx.snap
new file mode 100644 (file)
index 0000000..9d4eaf6
--- /dev/null
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<SearchSelect
+  className="input-super-large"
+  components={
+    Object {
+      "Option": [Function],
+      "SingleValue": [Function],
+    }
+  }
+  defaultOptions={Array []}
+  isClearable={true}
+  loadOptions={[Function]}
+  noOptionsMessage={[Function]}
+  onChange={[MockFunction]}
+/>
+`;
+
+exports[`should render correctly: logged in & assignable issues 1`] = `
+<SearchSelect
+  className="input-super-large"
+  components={
+    Object {
+      "Option": [Function],
+      "SingleValue": [Function],
+    }
+  }
+  defaultOptions={
+    Array [
+      Object {
+        "avatar": undefined,
+        "label": "Skywalker",
+        "value": "luke",
+      },
+    ]
+  }
+  isClearable={true}
+  loadOptions={[Function]}
+  noOptionsMessage={[Function]}
+  onChange={[MockFunction]}
+/>
+`;
+
+exports[`should render correctly: logged in & no assignable issues 1`] = `
+<SearchSelect
+  className="input-super-large"
+  components={
+    Object {
+      "Option": [Function],
+      "SingleValue": [Function],
+    }
+  }
+  defaultOptions={Array []}
+  isClearable={true}
+  loadOptions={[Function]}
+  noOptionsMessage={[Function]}
+  onChange={[MockFunction]}
+/>
+`;
+
+exports[`should render correctly: unassignable issues 1`] = `
+<SearchSelect
+  className="input-super-large"
+  components={
+    Object {
+      "Option": [Function],
+      "SingleValue": [Function],
+    }
+  }
+  defaultOptions={
+    Array [
+      Object {
+        "label": "unassigned",
+        "value": "",
+      },
+    ]
+  }
+  isClearable={true}
+  loadOptions={[Function]}
+  noOptionsMessage={[Function]}
+  onChange={[MockFunction]}
+/>
+`;
index d79ff90dddbf87e393de05ab1c3b2b14f8e58ce8..444ba88d326c5b64e68b08a6d2f4f1203b021304 100644 (file)
@@ -131,26 +131,6 @@ exports[`should display warning when too many issues are passed 2`] = `
 </Alert>
 `;
 
-exports[`should properly handle the search for assignee 1`] = `
-Array [
-  Object {
-    "avatar": "##toto",
-    "label": "toto",
-    "value": "toto@toto",
-  },
-  Object {
-    "avatar": "##toto",
-    "label": "user.x_deleted.toto",
-    "value": "login@login",
-  },
-  Object {
-    "avatar": "##toto",
-    "label": "user.x_deleted.login@login",
-    "value": "login@login",
-  },
-]
-`;
-
 exports[`should render select for severity: Option 1`] = `
 <Option
   data={
index cfe46334afeff9b1befb548681e63614e84ca110..12e94c24f5ecb7ac2175fec3d129f3466694f0e8 100644 (file)
@@ -18,8 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { components, OptionProps } from 'react-select';
 import RadioCard from '../../../components/controls/RadioCard';
-import SearchSelect from '../../../components/controls/SearchSelect';
+import Select from '../../../components/controls/Select';
 import Tooltip from '../../../components/controls/Tooltip';
 import AlertErrorIcon from '../../../components/icons/AlertErrorIcon';
 import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker';
@@ -40,32 +41,45 @@ export interface BaselineSettingReferenceBranchProps {
 }
 
 export interface BranchOption {
-  disabled?: boolean;
+  isDisabled?: boolean;
   isInvalid?: boolean;
   isMain: boolean;
+  label: string;
   value: string;
 }
 
-function renderBranchOption(option: BranchOption) {
-  return option.isInvalid ? (
-    <Tooltip
-      overlay={translateWithParameters('baseline.reference_branch.does_not_exist', option.value)}>
-      <span>
-        {option.value} <AlertErrorIcon />
-      </span>
-    </Tooltip>
-  ) : (
-    <>
-      <span
-        title={
-          option.disabled ? translate('baseline.reference_branch.cannot_be_itself') : undefined
-        }>
-        {option.value}
-      </span>
-      {option.isMain && (
-        <div className="badge spacer-left">{translate('branches.main_branch')}</div>
+/* Export for testing */
+export function renderBranchOption(props: OptionProps<BranchOption, false>) {
+  const { data: option } = props;
+
+  return (
+    <components.Option {...props}>
+      {option.isInvalid ? (
+        <Tooltip
+          overlay={translateWithParameters(
+            'baseline.reference_branch.does_not_exist',
+            option.value
+          )}>
+          <span>
+            {option.value} <AlertErrorIcon />
+          </span>
+        </Tooltip>
+      ) : (
+        <>
+          <span
+            title={
+              option.isDisabled
+                ? translate('baseline.reference_branch.cannot_be_itself')
+                : undefined
+            }>
+            {option.value}
+          </span>
+          {option.isMain && (
+            <div className="badge spacer-left">{translate('branches.main_branch')}</div>
+          )}
+        </>
       )}
-    </>
+    </components.Option>
   );
 }
 
@@ -73,6 +87,7 @@ export default function BaselineSettingReferenceBranch(props: BaselineSettingRef
   const { branchList, className, disabled, referenceBranch, selected, settingLevel } = props;
 
   const currentBranch = branchList.find(b => b.value === referenceBranch) || {
+    label: referenceBranch,
     value: referenceBranch,
     isMain: false,
     isInvalid: true
@@ -98,15 +113,15 @@ export default function BaselineSettingReferenceBranch(props: BaselineSettingRef
                 <strong>{translate('baseline.reference_branch.choose')}</strong>
                 <MandatoryFieldMarker />
               </label>
-              <SearchSelect<BranchOption>
+              <Select<BranchOption>
                 autofocus={false}
                 className="little-spacer-top spacer-bottom"
-                defaultOptions={branchList}
-                minimumQueryLength={1}
-                onSearch={q => Promise.resolve(branchList.filter(b => b.value.includes(q)))}
-                onSelect={option => props.onChangeReferenceBranch(option.value)}
-                renderOption={renderBranchOption}
+                options={branchList}
+                onChange={(option: BranchOption) => props.onChangeReferenceBranch(option.value)}
                 value={currentBranch}
+                components={{
+                  Option: renderBranchOption
+                }}
               />
             </div>
           </>
index 7b2d84aa6e68d73029e14bb9553747feb227507f..b3b074509f499c7ad01486ab1b96b08268ab793f 100644 (file)
@@ -82,9 +82,10 @@ export default class BranchBaselineSettingModal extends React.PureComponent<Prop
   }
 
   branchToOption = (b: Branch) => ({
+    label: b.name,
     value: b.name,
     isMain: b.isMain,
-    disabled: b.name === this.props.branch.name // cannot itself be used as a reference branch
+    isDisabled: b.name === this.props.branch.name // cannot itself be used as a reference branch
   });
 
   handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => {
index 0fec9735c38e5bb91eb9d5aea33eaf5a18c59826..190f93fcb13a16cd40a74cfc17f9daec42dcd72e 100644 (file)
@@ -78,7 +78,7 @@ function renderGeneralSetting(generalSetting: NewCodePeriod) {
 }
 
 function branchToOption(b: Branch) {
-  return { value: b.name, isMain: b.isMain };
+  return { label: b.name, value: b.name, isMain: b.isMain };
 }
 
 export default function ProjectBaselineSelector(props: ProjectBaselineSelectorProps) {
index 8d73da812a57667d1ba8a63ce2419467c3163554..7ac3867884adf99828a38fa04907d2153a013ad8 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { OptionProps, Props as ReactSelectProps } from 'react-select';
 import RadioCard from '../../../../components/controls/RadioCard';
-import SearchSelect from '../../../../components/controls/SearchSelect';
+import Select from '../../../../components/controls/Select';
 import BaselineSettingReferenceBranch, {
   BaselineSettingReferenceBranchProps,
-  BranchOption
+  BranchOption,
+  renderBranchOption
 } from '../BaselineSettingReferenceBranch';
 
 it('should render correctly', () => {
@@ -33,7 +35,7 @@ it('should render correctly', () => {
   );
   expect(
     shallowRender({
-      branchList: [{ value: 'master', isMain: true }],
+      branchList: [{ label: 'master', value: 'master', isMain: true }],
       settingLevel: 'branch',
       configuredBranchName: 'master'
     })
@@ -61,9 +63,9 @@ it('should callback when changing selection', () => {
   const wrapper = shallowRender({ onChangeReferenceBranch });
 
   wrapper
-    .find(SearchSelect)
+    .find(Select)
     .first()
-    .simulate('select', { value: 'branch-6.9' });
+    .simulate('change', { value: 'branch-6.9' });
   expect(onChangeReferenceBranch).toHaveBeenCalledWith('branch-6.9');
 });
 
@@ -73,34 +75,45 @@ it('should handle an invalid branch', () => {
 
   expect(
     wrapper
-      .find(SearchSelect)
+      .find<ReactSelectProps>(Select)
       .first()
       .props().value
-  ).toEqual({ value: unknownBranchName, isMain: false, isInvalid: true });
+  ).toEqual({ label: unknownBranchName, value: unknownBranchName, isMain: false, isInvalid: true });
 });
 
 describe('renderOption', () => {
-  const select = shallowRender()
-    .find(SearchSelect)
-    .first();
-  const renderFunction = select.props().renderOption as (option: BranchOption) => JSX.Element;
+  // fake props injected by the Select itself
+  const props = {} as OptionProps<BranchOption, false>;
 
   it('should render correctly', () => {
-    expect(renderFunction({ value: 'master', isMain: true })).toMatchSnapshot('main');
-    expect(renderFunction({ value: 'branch-7.4', isMain: false })).toMatchSnapshot('branch');
-    expect(renderFunction({ value: 'disabled', isMain: false, disabled: true })).toMatchSnapshot(
-      'disabled'
-    );
     expect(
-      renderFunction({ value: 'branch-nope', isMain: false, isInvalid: true })
+      renderBranchOption({ ...props, data: { label: 'master', value: 'master', isMain: true } })
+    ).toMatchSnapshot('main');
+    expect(
+      renderBranchOption({
+        ...props,
+        data: { label: 'branch-7.4', value: 'branch-7.4', isMain: false }
+      })
+    ).toMatchSnapshot('branch');
+    expect(
+      renderBranchOption({
+        ...props,
+        data: { label: 'disabled', value: 'disabled', isMain: false, isDisabled: true }
+      })
+    ).toMatchSnapshot('disabled');
+    expect(
+      renderBranchOption({
+        ...props,
+        data: { value: 'branch-nope', isMain: false, isInvalid: true }
+      })
     ).toMatchSnapshot("branch doesn't exist");
   });
 });
 
 function shallowRender(props: Partial<BaselineSettingReferenceBranchProps> = {}) {
   const branchOptions = [
-    { value: 'master', isMain: true },
-    { value: 'branch-7.9', isMain: false }
+    { label: 'master', value: 'master', isMain: true },
+    { label: 'branch-7.9', value: 'branch-7.9', isMain: false }
   ];
 
   return shallow(
index 7ebf3e41e4f13fffbb1ddbc98a73fe1d574d6745..d4f3b6d553441a4aa2ace6bc480f2805e29363e6 100644 (file)
@@ -1,46 +1,87 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`renderOption should render correctly: branch 1`] = `
-<React.Fragment>
-  <span>
-    branch-7.4
-  </span>
-</React.Fragment>
+<Option
+  data={
+    Object {
+      "isMain": false,
+      "label": "branch-7.4",
+      "value": "branch-7.4",
+    }
+  }
+>
+  <React.Fragment>
+    <span>
+      branch-7.4
+    </span>
+  </React.Fragment>
+</Option>
 `;
 
 exports[`renderOption should render correctly: branch doesn't exist 1`] = `
-<Tooltip
-  overlay="baseline.reference_branch.does_not_exist.branch-nope"
+<Option
+  data={
+    Object {
+      "isInvalid": true,
+      "isMain": false,
+      "value": "branch-nope",
+    }
+  }
 >
-  <span>
-    branch-nope
-     
-    <AlertErrorIcon />
-  </span>
-</Tooltip>
+  <Tooltip
+    overlay="baseline.reference_branch.does_not_exist.branch-nope"
+  >
+    <span>
+      branch-nope
+       
+      <AlertErrorIcon />
+    </span>
+  </Tooltip>
+</Option>
 `;
 
 exports[`renderOption should render correctly: disabled 1`] = `
-<React.Fragment>
-  <span
-    title="baseline.reference_branch.cannot_be_itself"
-  >
-    disabled
-  </span>
-</React.Fragment>
+<Option
+  data={
+    Object {
+      "isDisabled": true,
+      "isMain": false,
+      "label": "disabled",
+      "value": "disabled",
+    }
+  }
+>
+  <React.Fragment>
+    <span
+      title="baseline.reference_branch.cannot_be_itself"
+    >
+      disabled
+    </span>
+  </React.Fragment>
+</Option>
 `;
 
 exports[`renderOption should render correctly: main 1`] = `
-<React.Fragment>
-  <span>
-    master
-  </span>
-  <div
-    className="badge spacer-left"
-  >
-    branches.main_branch
-  </div>
-</React.Fragment>
+<Option
+  data={
+    Object {
+      "isMain": true,
+      "label": "master",
+      "value": "master",
+    }
+  }
+>
+  <React.Fragment>
+    <span>
+      master
+    </span>
+    <div
+      className="badge spacer-left"
+    >
+      branches.main_branch
+    </div>
+  </React.Fragment>
+</Option>
 `;
 
 exports[`should render correctly: Branch level - no other branches 1`] = `
@@ -67,24 +108,28 @@ exports[`should render correctly: Branch level - no other branches 1`] = `
       </strong>
       <MandatoryFieldMarker />
     </label>
-    <SearchSelect
+    <Select
       autofocus={false}
       className="little-spacer-top spacer-bottom"
-      defaultOptions={
+      components={
+        Object {
+          "Option": [Function],
+        }
+      }
+      onChange={[Function]}
+      options={
         Array [
           Object {
             "isMain": true,
+            "label": "master",
             "value": "master",
           },
         ]
       }
-      minimumQueryLength={1}
-      onSearch={[Function]}
-      onSelect={[Function]}
-      renderOption={[Function]}
       value={
         Object {
           "isMain": true,
+          "label": "master",
           "value": "master",
         }
       }
@@ -117,28 +162,33 @@ exports[`should render correctly: Branch level 1`] = `
       </strong>
       <MandatoryFieldMarker />
     </label>
-    <SearchSelect
+    <Select
       autofocus={false}
       className="little-spacer-top spacer-bottom"
-      defaultOptions={
+      components={
+        Object {
+          "Option": [Function],
+        }
+      }
+      onChange={[Function]}
+      options={
         Array [
           Object {
             "isMain": true,
+            "label": "master",
             "value": "master",
           },
           Object {
             "isMain": false,
+            "label": "branch-7.9",
             "value": "branch-7.9",
           },
         ]
       }
-      minimumQueryLength={1}
-      onSearch={[Function]}
-      onSelect={[Function]}
-      renderOption={[Function]}
       value={
         Object {
           "isMain": true,
+          "label": "master",
           "value": "master",
         }
       }
@@ -176,28 +226,33 @@ exports[`should render correctly: Project level 1`] = `
       </strong>
       <MandatoryFieldMarker />
     </label>
-    <SearchSelect
+    <Select
       autofocus={false}
       className="little-spacer-top spacer-bottom"
-      defaultOptions={
+      components={
+        Object {
+          "Option": [Function],
+        }
+      }
+      onChange={[Function]}
+      options={
         Array [
           Object {
             "isMain": true,
+            "label": "master",
             "value": "master",
           },
           Object {
             "isMain": false,
+            "label": "branch-7.9",
             "value": "branch-7.9",
           },
         ]
       }
-      minimumQueryLength={1}
-      onSearch={[Function]}
-      onSelect={[Function]}
-      renderOption={[Function]}
       value={
         Object {
           "isMain": true,
+          "label": "master",
           "value": "master",
         }
       }
index faeab231d182e9e60b561de1856c0716a44ce1d0..03eb20e011e2ac6d87882b1a0d341199b47b5b69 100644 (file)
@@ -44,13 +44,15 @@ exports[`should render correctly: multiple branches 1`] = `
           branchList={
             Array [
               Object {
-                "disabled": true,
+                "isDisabled": true,
                 "isMain": true,
+                "label": "master",
                 "value": "master",
               },
               Object {
-                "disabled": false,
+                "isDisabled": false,
                 "isMain": false,
+                "label": "branch-6.7",
                 "value": "branch-6.7",
               },
             ]
@@ -129,8 +131,9 @@ exports[`should render correctly: only one branch 1`] = `
           branchList={
             Array [
               Object {
-                "disabled": true,
+                "isDisabled": true,
                 "isMain": true,
+                "label": "master",
                 "value": "master",
               },
             ]
index a0fe096a3b61ee82325ef7943f0950e77886d532..bb2a408d107bb5b43843d4be3f0ef9ba088ea198 100644 (file)
@@ -65,6 +65,7 @@ exports[`should render correctly 1`] = `
           Array [
             Object {
               "isMain": true,
+              "label": "master",
               "value": "master",
             },
           ]
diff --git a/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx b/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx
deleted file mode 100644 (file)
index c89bd94..0000000
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2022 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 { debounce } from 'lodash';
-import * as React from 'react';
-import { translate, translateWithParameters } from '../../helpers/l10n';
-import SelectLegacy, { CreatableLegacy } from './SelectLegacy';
-
-interface Props<T> {
-  autofocus?: boolean;
-  canCreate?: boolean;
-  className?: string;
-  clearable?: boolean;
-  defaultOptions?: T[];
-  minimumQueryLength?: number;
-  multi?: boolean;
-  onSearch: (query: string) => Promise<T[]>;
-  onSelect?: (option: T) => void;
-  onMultiSelect?: (options: T[]) => void;
-  promptTextCreator?: (label: string) => string;
-  renderOption?: (option: T) => JSX.Element;
-  resetOnBlur?: boolean;
-  value?: T | T[];
-}
-
-interface State<T> {
-  loading: boolean;
-  options: T[];
-  query: string;
-}
-
-export default class SearchSelect<T extends { value: string }> extends React.PureComponent<
-  Props<T>,
-  State<T>
-> {
-  mounted = false;
-
-  constructor(props: Props<T>) {
-    super(props);
-    this.state = { loading: false, options: props.defaultOptions || [], query: '' };
-    this.handleSearch = debounce(this.handleSearch, 250);
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  get autofocus() {
-    return this.props.autofocus !== undefined ? this.props.autofocus : true;
-  }
-
-  get minimumQueryLength() {
-    return this.props.minimumQueryLength !== undefined ? this.props.minimumQueryLength : 2;
-  }
-
-  get resetOnBlur() {
-    return this.props.resetOnBlur !== undefined ? this.props.resetOnBlur : true;
-  }
-
-  handleSearch = (query: string) => {
-    // Ignore the result if the query changed
-    const currentQuery = query;
-    this.props.onSearch(currentQuery).then(
-      options => {
-        if (this.mounted) {
-          this.setState(state => ({
-            loading: false,
-            options: state.query === currentQuery ? options : state.options
-          }));
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      }
-    );
-  };
-
-  handleChange = (option: T | T[]) => {
-    if (Array.isArray(option)) {
-      if (this.props.onMultiSelect) {
-        this.props.onMultiSelect(option);
-      }
-    } else if (this.props.onSelect) {
-      this.props.onSelect(option);
-    }
-  };
-
-  handleInputChange = (query: string) => {
-    if (query.length >= this.minimumQueryLength) {
-      this.setState({ loading: true, query });
-      this.handleSearch(query);
-    } else {
-      // `onInputChange` is called with an empty string after a user selects a value
-      // in this case we shouldn't reset `options`, because it also resets select value :(
-      const options = (query.length === 0 && this.props.defaultOptions) || [];
-      this.setState({ options, query });
-    }
-  };
-
-  // disable internal filtering
-  handleFilterOption = () => true;
-
-  render() {
-    const Component = this.props.canCreate ? CreatableLegacy : SelectLegacy;
-    return (
-      <Component
-        autoFocus={this.autofocus}
-        className={this.props.className}
-        clearable={this.props.clearable}
-        escapeClearsValue={false}
-        filterOption={this.handleFilterOption}
-        isLoading={this.state.loading}
-        multi={this.props.multi}
-        noResultsText={
-          this.state.query.length < this.minimumQueryLength
-            ? translateWithParameters('select2.tooShort', this.minimumQueryLength)
-            : translate('select2.noMatches')
-        }
-        onBlurResetsInput={this.resetOnBlur}
-        onChange={this.handleChange}
-        onInputChange={this.handleInputChange}
-        optionRenderer={this.props.renderOption}
-        options={this.state.options}
-        placeholder={translate('search_verb')}
-        promptTextCreator={this.props.promptTextCreator}
-        searchable={true}
-        value={this.props.value}
-        valueRenderer={this.props.renderOption}
-      />
-    );
-  }
-}
index 4273c2fbbb05dd8fde682aeea6cd55f6f725539f..42862806a66a916be835eb0cf20536cc7e602480 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import styled from '@emotion/styled';
+import classNames from 'classnames';
 import * as React from 'react';
-import ReactSelect, { GroupTypeBase, IndicatorProps, Props, StylesConfig } from 'react-select';
+import ReactSelect, {
+  GroupTypeBase,
+  IndicatorProps,
+  OptionTypeBase,
+  Props,
+  StylesConfig
+} from 'react-select';
+import AsyncReactSelect, { AsyncProps } from 'react-select/async';
+import AsyncCreatableReactSelect, {
+  Props as AsyncCreatableProps
+} from 'react-select/async-creatable';
+import { LoadingIndicatorProps } from 'react-select/src/components/indicators';
 import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
 import { colors, others, sizes, zIndexes } from '../../app/theme';
 import { ClearButton } from './buttons';
@@ -38,29 +50,51 @@ export interface BasicSelectOption {
   value: string;
 }
 
-export default class Select<
-  Option,
+export function dropdownIndicator<
+  Option extends OptionTypeBase,
   IsMulti extends boolean = false,
   Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
-> extends React.PureComponent<Props<Option, IsMulti, Group>> {
-  dropdownIndicator({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
-    return <ArrowSpan {...innerProps} />;
-  }
+>({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
+  return <ArrowSpan {...innerProps} />;
+}
 
-  clearIndicator({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
-    return (
-      <ClearButton
-        className="button-tiny spacer-left spacer-right text-middle"
-        iconProps={{ size: 12 }}
-        {...innerProps}
-      />
-    );
-  }
+export function clearIndicator<
+  Option extends OptionTypeBase,
+  IsMulti extends boolean = false,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+>({ innerProps }: IndicatorProps<Option, IsMulti, Group>) {
+  return (
+    <ClearButton
+      className="button-tiny spacer-left spacer-right text-middle"
+      iconProps={{ size: 12 }}
+      {...innerProps}
+    />
+  );
+}
 
-  multiValueRemove(props: MultiValueRemoveProps<Option, Group>) {
-    return <div {...props.innerProps}>×</div>;
-  }
+export function loadingIndicator<
+  Option extends OptionTypeBase,
+  IsMulti extends boolean,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+>({ innerProps }: LoadingIndicatorProps<Option, IsMulti, Group>) {
+  return (
+    <i className={classNames('deferred-spinner spacer-left spacer-right', innerProps.className)} />
+  );
+}
 
+export function multiValueRemove<
+  Option extends OptionTypeBase,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+>(props: MultiValueRemoveProps<Option, Group>) {
+  return <div {...props.innerProps}>×</div>;
+}
+
+/* Keeping it as a class to simplify a dozen tests */
+export default class Select<
+  Option,
+  IsMulti extends boolean = false,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+> extends React.Component<Props<Option, IsMulti, Group>> {
   render() {
     return (
       <ReactSelect
@@ -68,15 +102,55 @@ export default class Select<
         styles={selectStyle<Option, IsMulti, Group>()}
         components={{
           ...this.props.components,
-          DropdownIndicator: this.dropdownIndicator,
-          ClearIndicator: this.clearIndicator,
-          MultiValueRemove: this.multiValueRemove
+          DropdownIndicator: dropdownIndicator,
+          ClearIndicator: clearIndicator,
+          MultiValueRemove: multiValueRemove
         }}
       />
     );
   }
 }
 
+export function CreatableSelect<
+  Option,
+  isMulti extends boolean,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+>(props: AsyncCreatableProps<Option, isMulti, Group>) {
+  return (
+    <AsyncCreatableReactSelect
+      {...props}
+      styles={selectStyle<Option, isMulti, Group>()}
+      components={{
+        ...props.components,
+        DropdownIndicator: dropdownIndicator,
+        ClearIndicator: clearIndicator,
+        MultiValueRemove: multiValueRemove,
+        LoadingIndicator: loadingIndicator
+      }}
+    />
+  );
+}
+
+export function SearchSelect<
+  Option,
+  isMulti extends boolean,
+  Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
+>(props: Props<Option, isMulti, Group> & AsyncProps<Option, Group>) {
+  return (
+    <AsyncReactSelect
+      {...props}
+      styles={selectStyle<Option, isMulti, Group>()}
+      components={{
+        ...props.components,
+        DropdownIndicator: dropdownIndicator,
+        ClearIndicator: clearIndicator,
+        MultiValueRemove: multiValueRemove,
+        LoadingIndicator: loadingIndicator
+      }}
+    />
+  );
+}
+
 export function selectStyle<
   Option,
   IsMulti extends boolean,
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx
deleted file mode 100644 (file)
index b434928..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2022 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 SearchSelect from '../SearchSelect';
-
-jest.mock('lodash', () => {
-  const lodash = jest.requireActual('lodash');
-  lodash.debounce = jest.fn(fn => fn);
-  return lodash;
-});
-
-it('should render Select', () => {
-  expect(shallow(<SearchSelect onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot();
-});
-
-it('should call onSelect', () => {
-  const onSelect = jest.fn();
-  const wrapper = shallow(<SearchSelect onSearch={jest.fn()} onSelect={onSelect} />);
-  wrapper.prop('onChange')({ value: 'foo' });
-  expect(onSelect).lastCalledWith({ value: 'foo' });
-});
-
-it('should call onSearch', () => {
-  const onSearch = jest.fn().mockReturnValue(Promise.resolve([]));
-  const wrapper = shallow(
-    <SearchSelect minimumQueryLength={2} onSearch={onSearch} onSelect={jest.fn()} />
-  );
-  wrapper.prop('onInputChange')('f');
-  expect(onSearch).not.toHaveBeenCalled();
-  wrapper.prop('onInputChange')('foo');
-  expect(onSearch).lastCalledWith('foo');
-});
index 57a924e18bbe43cb20dd76ceb846c895ab2bc683..840cef0a826d7aedc34548482a825092a68d6c36 100644 (file)
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
-import { components, GroupTypeBase, InputProps, Props } from 'react-select';
+import { components, GroupTypeBase, InputProps, Props as ReactSelectProps } from 'react-select';
+import { LoadingIndicatorProps } from 'react-select/src/components/indicators';
+import { MultiValueRemoveProps } from 'react-select/src/components/MultiValue';
 import { mockReactSelectIndicatorProps } from '../../../helpers/mocks/react-select';
-import Select from '../Select';
+import Select, {
+  clearIndicator,
+  CreatableSelect,
+  dropdownIndicator,
+  loadingIndicator,
+  multiValueRemove,
+  SearchSelect
+} from '../Select';
 
 describe('Select', () => {
   it('should render correctly', () => {
@@ -33,35 +42,54 @@ describe('Select', () => {
       <components.Input {...props} className={`little-spacer-top ${props.className}`} />
     );
 
-    const props = {
-      isClearable: true,
-      isLoading: true,
-      components: {
-        Input: inputRenderer
-      }
-    };
-    expect(shallowRender(props)).toMatchSnapshot('other props');
+    expect(
+      shallowRender({
+        isClearable: true,
+        isLoading: true,
+        components: {
+          Input: inputRenderer
+        }
+      })
+    ).toMatchSnapshot('other props');
   });
 
   it('should render clearIndicator correctly', () => {
-    const wrapper = shallowRender();
-    const ClearIndicator = wrapper.instance().clearIndicator;
-    const clearIndicator = shallow(<ClearIndicator {...mockReactSelectIndicatorProps()} />);
-    expect(clearIndicator).toBeDefined();
+    expect(clearIndicator(mockReactSelectIndicatorProps({ value: '' }))).toMatchSnapshot();
   });
 
   it('should render dropdownIndicator correctly', () => {
-    const wrapper = shallowRender();
-    const DropdownIndicator = wrapper.instance().dropdownIndicator;
-    const clearIndicator = shallow(<DropdownIndicator {...mockReactSelectIndicatorProps()} />);
-    expect(clearIndicator).toBeDefined();
+    expect(dropdownIndicator(mockReactSelectIndicatorProps({ value: '' }))).toMatchSnapshot();
+  });
+
+  it('should render loadingIndicator correctly', () => {
+    expect(
+      loadingIndicator({ innerProps: { className: 'additional-class' } } as LoadingIndicatorProps<
+        {},
+        false
+      >)
+    ).toMatchSnapshot();
+  });
+
+  it('should render multiValueRemove correctly', () => {
+    expect(multiValueRemove({ innerProps: {} } as MultiValueRemoveProps<{}>)).toMatchSnapshot();
   });
 
   function shallowRender<
     Option,
     IsMulti extends boolean = false,
     Group extends GroupTypeBase<Option> = GroupTypeBase<Option>
-  >(props: Partial<Props<Option, IsMulti, Group>> = {}) {
-    return shallow<Select<Option, IsMulti, Group>>(<Select {...props} />);
+  >(props: Partial<ReactSelectProps<Option, IsMulti, Group>> = {}) {
+    return shallow<ReactSelectProps<Option, IsMulti, Group>>(<Select {...props} />);
   }
 });
+
+it.each([
+  ['CreatableSelect', CreatableSelect],
+  ['SearchSelect', SearchSelect]
+])('should render %s correctly', (_name, Component) => {
+  expect(
+    shallow(<Component />)
+      .dive()
+      .dive()
+  ).toMatchSnapshot();
+});
diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap
deleted file mode 100644 (file)
index 9ee6e2d..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render Select 1`] = `
-<SelectLegacy
-  autoFocus={true}
-  escapeClearsValue={false}
-  filterOption={[Function]}
-  isLoading={false}
-  noResultsText="select2.tooShort.2"
-  onBlurResetsInput={true}
-  onChange={[Function]}
-  onInputChange={[Function]}
-  options={Array []}
-  placeholder="search_verb"
-  searchable={true}
-/>
-`;
index a8ecb8b67ffd0d8e92f6cd0dcb978f1af919327f..eff8131e9a9261dfe68f6fccc3cb730423e58342 100644 (file)
@@ -1,5 +1,16 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`Select should render clearIndicator correctly 1`] = `
+<ClearButton
+  className="button-tiny spacer-left spacer-right text-middle"
+  iconProps={
+    Object {
+      "size": 12,
+    }
+  }
+/>
+`;
+
 exports[`Select should render complex select component: other props 1`] = `
 <StateManager
   components={
@@ -70,3 +81,143 @@ exports[`Select should render correctly: default 1`] = `
   }
 />
 `;
+
+exports[`Select should render dropdownIndicator correctly 1`] = `<Styled(span) />`;
+
+exports[`Select should render loadingIndicator correctly 1`] = `
+<i
+  className="deferred-spinner spacer-left spacer-right additional-class"
+/>
+`;
+
+exports[`Select should render multiValueRemove correctly 1`] = `
+<div>
+  ×
+</div>
+`;
+
+exports[`should render CreatableSelect correctly 1`] = `
+<Creatable
+  allowCreateWhileLoading={false}
+  cacheOptions={false}
+  components={
+    Object {
+      "ClearIndicator": [Function],
+      "DropdownIndicator": [Function],
+      "LoadingIndicator": [Function],
+      "MultiValueRemove": [Function],
+    }
+  }
+  createOptionPosition="last"
+  defaultOptions={false}
+  filterOption={null}
+  formatCreateLabel={[Function]}
+  getNewOptionData={[Function]}
+  getOptionLabel={[Function]}
+  getOptionValue={[Function]}
+  inputValue=""
+  isLoading={false}
+  isValidNewOption={[Function]}
+  menuIsOpen={false}
+  onChange={[Function]}
+  onInputChange={[Function]}
+  onMenuClose={[Function]}
+  onMenuOpen={[Function]}
+  options={Array []}
+  styles={
+    Object {
+      "container": [Function],
+      "control": [Function],
+      "indicatorsContainer": [Function],
+      "input": [Function],
+      "loadingIndicator": [Function],
+      "menu": [Function],
+      "menuList": [Function],
+      "multiValue": [Function],
+      "multiValueLabel": [Function],
+      "multiValueRemove": [Function],
+      "noOptionsMessage": [Function],
+      "option": [Function],
+      "placeholder": [Function],
+      "singleValue": [Function],
+      "valueContainer": [Function],
+    }
+  }
+  value={null}
+/>
+`;
+
+exports[`should render SearchSelect correctly 1`] = `
+<Select
+  aria-live="polite"
+  backspaceRemovesValue={true}
+  blurInputOnSelect={true}
+  cacheOptions={false}
+  captureMenuScroll={false}
+  closeMenuOnScroll={false}
+  closeMenuOnSelect={true}
+  components={
+    Object {
+      "ClearIndicator": [Function],
+      "DropdownIndicator": [Function],
+      "LoadingIndicator": [Function],
+      "MultiValueRemove": [Function],
+    }
+  }
+  controlShouldRenderValue={true}
+  defaultOptions={false}
+  escapeClearsValue={false}
+  filterOption={null}
+  formatGroupLabel={[Function]}
+  getOptionLabel={[Function]}
+  getOptionValue={[Function]}
+  inputValue=""
+  isDisabled={false}
+  isLoading={false}
+  isMulti={false}
+  isOptionDisabled={[Function]}
+  isRtl={false}
+  isSearchable={true}
+  loadingMessage={[Function]}
+  maxMenuHeight={300}
+  menuIsOpen={false}
+  menuPlacement="bottom"
+  menuPosition="absolute"
+  menuShouldBlockScroll={false}
+  menuShouldScrollIntoView={true}
+  minMenuHeight={140}
+  noOptionsMessage={[Function]}
+  onChange={[Function]}
+  onInputChange={[Function]}
+  onMenuClose={[Function]}
+  onMenuOpen={[Function]}
+  openMenuOnClick={true}
+  openMenuOnFocus={false}
+  options={Array []}
+  pageSize={5}
+  placeholder="Select..."
+  screenReaderStatus={[Function]}
+  styles={
+    Object {
+      "container": [Function],
+      "control": [Function],
+      "indicatorsContainer": [Function],
+      "input": [Function],
+      "loadingIndicator": [Function],
+      "menu": [Function],
+      "menuList": [Function],
+      "multiValue": [Function],
+      "multiValueLabel": [Function],
+      "multiValueRemove": [Function],
+      "noOptionsMessage": [Function],
+      "option": [Function],
+      "placeholder": [Function],
+      "singleValue": [Function],
+      "valueContainer": [Function],
+    }
+  }
+  tabIndex="0"
+  tabSelectsValue={true}
+  value={null}
+/>
+`;
index 05f49106ed496e089fa435cc8417f8a06f69297f..d25e6abf791b52623168faf0ad52d6cc2ca781f2 100644 (file)
@@ -49,6 +49,6 @@ export function mockReactSelectIndicatorProps<
   OptionType,
   IsMulti extends boolean,
   GroupType extends GroupTypeBase<OptionType> = GroupTypeBase<OptionType>
->(): IndicatorProps<OptionType, IsMulti, GroupType> {
+>(_option: OptionType): IndicatorProps<OptionType, IsMulti, GroupType> {
   return {} as IndicatorProps<OptionType, IsMulti, GroupType>;
 }
index fbd4e5f4e7508b55c5fa3c97872b380510ac6df5..b6ce448d7b49d9eebf395d75da84b2a93979275c 100644 (file)
@@ -802,6 +802,7 @@ issues.on_file_x=Issues on file {0}
 issue.add_tags=Add Tags
 issue.remove_tags=Remove Tags
 issue.no_tag=No tags
+issue.create_tag_x=Create Tag '{0}'
 issue.assign.assigned_to_x_click_to_change=Assigned to {0}, click to change
 issue.assign.unassigned_click_to_assign=Unassigned, click to assign issue
 issue.assign.formlink=Assign