]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10047 Use SearchSelect for tags in Bulk Issue Change 3102/head
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Mon, 26 Feb 2018 15:49:40 +0000 (16:49 +0100)
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>
Tue, 27 Feb 2018 13:44:23 +0000 (14:44 +0100)
13 files changed:
server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js
server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js
server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js
server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js
server/sonar-web/src/main/js/components/controls/SearchSelect.tsx
server/sonar-web/src/main/js/components/controls/Select.tsx
server/sonar-web/src/main/js/components/controls/__tests__/SearchSelect-test.tsx
server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap
server/sonar-web/src/main/js/components/controls/react-select.css
server/sonar-web/src/main/js/components/facet/FacetFooter.tsx

index 9ca37cb5de4eb05ce3a69044e27443e88bb2f304..deb1a36043aa9380227990a4818d983e9c71f728 100644 (file)
@@ -28,12 +28,15 @@ interface Props extends BasicProps {
 }
 
 export default class TagFacet extends React.PureComponent<Props> {
-  handleSearch = (query: string) =>
-    getRuleTags({ organization: this.props.organization, ps: 50, q: query }).then(tags =>
+  handleSearch = (query: string) => {
+    return getRuleTags({ organization: this.props.organization, ps: 50, q: query }).then(tags =>
       tags.map(tag => ({ label: tag, value: tag }))
     );
+  };
 
-  handleSelect = (tag: string) => this.props.onChange({ tags: uniq([...this.props.values, tag]) });
+  handleSelect = (option: { value: string }) => {
+    this.props.onChange({ tags: uniq([...this.props.values, option.value]) });
+  };
 
   renderName = (tag: string) => (
     <>
index b9f7ef9014f4791368073485cfefdd11b91158ad..e8f9b22fd0ce68f0acb5beca93fb390b648e0ac7 100644 (file)
@@ -23,7 +23,7 @@ import { pickBy, sortBy } from 'lodash';
 import SearchSelect from '../../../components/controls/SearchSelect';
 import Checkbox from '../../../components/controls/Checkbox';
 import Modal from '../../../components/controls/Modal';
-import Select, { Creatable } from '../../../components/controls/Select';
+import Select from '../../../components/controls/Select';
 import Tooltip from '../../../components/controls/Tooltip';
 import MarkdownTips from '../../../components/common/MarkdownTips';
 import SeverityHelper from '../../../components/shared/SeverityHelper';
@@ -49,21 +49,21 @@ type Props = {|
 
 /*::
 type State = {|
+  initialTags: Array<{ label:string, value: string }>,
   issues: Array<Issue>,
   // used for initial loading of issues
   loading: boolean,
   paging?: Paging,
   // used when submitting a form
   submitting: boolean,
-  tags?: Array<string>,
 
   // form fields
-  addTags?: Array<string>,
-  assignee?: string,
+  addTags?: Array<{ label: string, value: string }>,
+  assignee?: { avatar?: string, label: string, value: string },
   comment?: string,
   notifications?: boolean,
   organization?: string,
-  removeTags?: Array<string>,
+  removeTags?: Array<{ label: string, value: string }>,
   severity?: string,
   transition?: string,
   type?: string
@@ -84,7 +84,7 @@ export default class BulkChangeModal extends React.PureComponent {
     if (props.organization && !organization) {
       organization = props.organization.key;
     }
-    this.state = { issues: [], loading: true, submitting: false, organization };
+    this.state = { initialTags: [], issues: [], loading: true, submitting: false, organization };
   }
 
   componentDidMount() {
@@ -93,16 +93,19 @@ export default class BulkChangeModal extends React.PureComponent {
     Promise.all([
       this.loadIssues(),
       searchIssueTags({ organization: this.state.organization })
-    ]).then(([issues, tags]) => {
-      if (this.mounted) {
-        this.setState({
-          issues: issues.issues,
-          loading: false,
-          paging: issues.paging,
-          tags
-        });
-      }
-    });
+    ]).then(
+      ([issues, tags]) => {
+        if (this.mounted) {
+          this.setState({
+            initialTags: tags.map(tag => ({ label: tag, value: tag })),
+            issues: issues.issues,
+            loading: false,
+            paging: issues.paging
+          });
+        }
+      },
+      () => {}
+    );
   }
 
   componentWillUnmount() {
@@ -142,12 +145,26 @@ export default class BulkChangeModal extends React.PureComponent {
     this.props.onClose();
   };
 
-  handleAssigneeSearch = (query /*: string */) => searchAssignees(query, this.state.organization);
+  handleAssigneeSearch = (query /*: string */) => {
+    return searchAssignees(query, this.state.organization);
+  };
 
-  handleAssigneeSelect = (assignee /*: string */) => {
+  handleAssigneeSelect = (assignee /*: { avatar?: string, label: string, value: string } */) => {
     this.setState({ assignee });
   };
 
+  handleTagsSearch = (query /*: string */) => {
+    return searchIssueTags({ organization: this.state.organization, q: query }).then(tags =>
+      tags.map(tag => ({ label: tag, value: tag }))
+    );
+  };
+
+  handleTagsSelect = (field /*: string */) => (
+    options /*: Array<{ label: string, value: string }> */
+  ) => {
+    this.setState({ [field]: options });
+  };
+
   handleFieldCheck = (field /*: string */) => (checked /*: boolean */) => {
     if (!checked) {
       this.setState({ [field]: undefined });
@@ -164,28 +181,24 @@ export default class BulkChangeModal extends React.PureComponent {
     this.setState({ [field]: value });
   };
 
-  handleMultiSelectFieldChange = (field /*: string */) => (
-    options /*: Array<{ value: string }> */
-  ) => {
-    this.setState({ [field]: options.map(option => option.value) });
-  };
-
   handleSubmit = (e /*: Event */) => {
     e.preventDefault();
+    /* eslint-disable camelcase */
     const query = pickBy(
       {
-        assign: this.state.assignee,
-        set_type: this.state.type,
-        set_severity: this.state.severity,
-        add_tags: this.state.addTags && this.state.addTags.join(),
-        remove_tags: this.state.removeTags && this.state.removeTags.join(),
-        do_transition: this.state.transition,
+        add_tags: this.state.addTags && this.state.addTags.map(t => t.value).join(),
+        assign: this.state.assignee && this.state.assignee.value,
         comment: this.state.comment,
-        sendNotifications: this.state.notifications
+        do_transition: this.state.transition,
+        remove_tags: this.state.removeTags && this.state.removeTags.map(t => t.value).join(),
+        sendNotifications: this.state.notifications,
+        set_severity: this.state.severity,
+        set_type: this.state.type
       },
       // remove null, but keep empty string
       x => x != null
     );
+    /* eslint-enable camelcase */
     const issueKeys = this.state.issues.map(issue => issue.key);
 
     this.setState({ submitting: true });
@@ -320,8 +333,8 @@ export default class BulkChangeModal extends React.PureComponent {
         clearable={false}
         id="type"
         onChange={this.handleSelectFieldChange('type')}
-        options={options}
         optionRenderer={optionRenderer}
+        options={options}
         searchable={false}
         value={this.state.type}
         valueRenderer={optionRenderer}
@@ -349,8 +362,8 @@ export default class BulkChangeModal extends React.PureComponent {
         clearable={false}
         id="severity"
         onChange={this.handleSelectFieldChange('severity')}
-        options={options}
         optionRenderer={option => <SeverityHelper severity={option.value} />}
+        options={options}
         searchable={false}
         value={this.state.severity}
         valueRenderer={option => <SeverityHelper severity={option.value} />}
@@ -361,28 +374,25 @@ export default class BulkChangeModal extends React.PureComponent {
   };
 
   renderTagsField = (field /*: string */, label /*: string */, allowCreate /*: boolean */) => {
+    const { initialTags } = this.state;
     const affected /*: number */ = this.state.issues.filter(hasAction('set_tags')).length;
 
-    if (this.state.tags == null || affected === 0) {
+    if (initialTags == null || affected === 0) {
       return null;
     }
 
-    const Component = allowCreate ? Creatable : Select;
-
-    const options = [...this.state.tags, ...(this.state[field] || [])].map(tag => ({
-      label: tag,
-      value: tag
-    }));
-
     const input = (
-      <Component
-        clearable={false}
+      <SearchSelect
+        canCreate={allowCreate}
+        defaultOptions={this.state.initialTags}
         id={field}
+        minimumQueryLength={0}
         multi={true}
-        onChange={this.handleMultiSelectFieldChange(field)}
-        options={options}
+        onMultiSelect={this.handleTagsSelect(field)}
+        onSearch={this.handleTagsSearch}
         promptTextCreator={promptCreateTag}
-        searchable={true}
+        renderOption={this.renderAssigneeOption}
+        resetOnBlur={false}
         value={this.state[field]}
       />
     );
index a2d1685f607324f6eaf0299d6e3d89ecfc276a15..3034677596ff4c312259c517df158fc431ff3d06 100644 (file)
@@ -84,9 +84,9 @@ export default class AssigneeFacet extends React.PureComponent {
     return searchAssignees(query, organization);
   };
 
-  handleSelect = (assignee /*: string */) => {
+  handleSelect = (option /*: { value: string } */) => {
     const { assignees } = this.props;
-    this.props.onChange({ assigned: true, [this.property]: uniq([...assignees, assignee]) });
+    this.props.onChange({ assigned: true, [this.property]: uniq([...assignees, option.value]) });
   };
 
   isAssigneeActive(assignee /*: string */) {
index fba2560cc8353a4f8c36af66eb095926b590b13c..3083a35b69b3e29e571220d60da012d97e062cee 100644 (file)
@@ -96,9 +96,9 @@ export default class ProjectFacet extends React.PureComponent {
     );
   };
 
-  handleSelect = (rule /*: string */) => {
+  handleSelect = (option /*: { value: string } */) => {
     const { projects } = this.props;
-    this.props.onChange({ [this.property]: uniq([...projects, rule]) });
+    this.props.onChange({ [this.property]: uniq([...projects, option.value]) });
   };
 
   getStat(project /*: string */) /*: ?number */ {
index 18704e1f4ff78bc7b1ba48e22bd6db63608661e2..b30780fa36350b0ee65ec22c8f99ddb0611e3c78 100644 (file)
@@ -80,9 +80,9 @@ export default class RuleFacet extends React.PureComponent {
     );
   };
 
-  handleSelect = (rule /*: string */) => {
+  handleSelect = (option /*: { value: string } */) => {
     const { rules } = this.props;
-    this.props.onChange({ [this.property]: uniq([...rules, rule]) });
+    this.props.onChange({ [this.property]: uniq([...rules, option.value]) });
   };
 
   getRuleName(rule /*: string */) /*: string */ {
index 51df80106ccabee11bdd31d0a09360d52453141b..8c62b5d48017214d275c75de2c18d59f6abdc6ff 100644 (file)
@@ -78,9 +78,9 @@ export default class TagFacet extends React.PureComponent {
     );
   };
 
-  handleSelect = (tag /*: string */) => {
+  handleSelect = (option /*: { value: string } */) => {
     const { tags } = this.props;
-    this.props.onChange({ [this.property]: uniq([...tags, tag]) });
+    this.props.onChange({ [this.property]: uniq([...tags, option.value]) });
   };
 
   getStat(tag /*: string */) /*: ?number */ {
index a2f956dc4a6b1d14800910e6183e6cd18d31fcaa..815472708ace318ca38fd9f131c289a5018a293f 100644 (file)
@@ -93,6 +93,6 @@ it('should handle footer callbacks', () => {
   const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange });
   const onSelect = wrapper.find('FacetFooter').prop('onSelect');
 
-  onSelect('qux');
+  onSelect({ value: 'qux' });
   expect(onChange).lastCalledWith({ assigned: true, assignees: ['foo', 'qux'] });
 });
index 22b1630f1a071cb5898b8bb94431d8e99393961b..1712ac8c4f01807ed1e66208063a1cc391942f38 100644 (file)
@@ -19,7 +19,7 @@
  */
 import * as React from 'react';
 import { debounce } from 'lodash';
-import Select from '../../components/controls/Select';
+import Select, { Creatable } from '../../components/controls/Select';
 import { translate, translateWithParameters } from '../../helpers/l10n';
 
 interface Option {
@@ -29,10 +29,14 @@ interface Option {
 
 interface Props {
   autofocus?: boolean;
+  canCreate?: boolean;
   defaultOptions?: Option[];
   minimumQueryLength?: number;
+  multi?: boolean;
   onSearch: (query: string) => Promise<Option[]>;
-  onSelect: (value: string) => void;
+  onSelect?: (option: Option) => void;
+  onMultiSelect?: (options: Option[]) => void;
+  promptTextCreator?: (label: string) => string;
   renderOption?: (option: Object) => JSX.Element;
   resetOnBlur?: boolean;
   value?: string;
@@ -73,11 +77,16 @@ export default class SearchSelect extends React.PureComponent<Props, State> {
     return this.props.resetOnBlur !== undefined ? this.props.resetOnBlur : true;
   }
 
-  handleSearch = (query: string) =>
-    this.props.onSearch(query).then(
+  handleSearch = (query: string) => {
+    // Ignore the result if the query changed
+    const currentQuery = query;
+    this.props.onSearch(currentQuery).then(
       options => {
         if (this.mounted) {
-          this.setState({ loading: false, options });
+          this.setState(state => ({
+            loading: false,
+            options: state.query === currentQuery ? options : state.options
+          }));
         }
       },
       () => {
@@ -86,16 +95,25 @@ export default class SearchSelect extends React.PureComponent<Props, State> {
         }
       }
     );
+  };
 
-  handleChange = (option: Option) => this.props.onSelect(option.value);
+  handleChange = (option: Option | Option[]) => {
+    if (Array.isArray(option)) {
+      if (this.props.onMultiSelect) {
+        this.props.onMultiSelect(option);
+      }
+    } else if (this.props.onSelect) {
+      this.props.onSelect(option);
+    }
+  };
 
   handleInputChange = (query: string) => {
-    // `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 :(
     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 });
     }
@@ -105,13 +123,16 @@ export default class SearchSelect extends React.PureComponent<Props, State> {
   handleFilterOption = () => true;
 
   render() {
+    const Component = this.props.canCreate ? Creatable : Select;
     return (
-      <Select
+      <Component
         autofocus={this.autofocus}
         className="input-super-large"
         clearable={false}
+        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)
@@ -123,6 +144,7 @@ export default class SearchSelect extends React.PureComponent<Props, State> {
         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 f8886578b8a5c3fa08ca393813ac484e46d7ee36..04e49151b88939d480f4259ccd121f381f87a3cc 100644 (file)
@@ -49,7 +49,7 @@ export default function Select({ innerRef, ...props }: WithInnerRef & ReactSelec
   // hide the "x" icon when select is empty
   const clearable = props.clearable ? Boolean(props.value) : false;
   return (
-    <ReactSelectAny {...props} clearable={clearable} clearRenderer={renderInput} ref={innerRef} />
+    <ReactSelectAny {...props} clearRenderer={renderInput} clearable={clearable} ref={innerRef} />
   );
 }
 
index 34d964549b48aa20c3a01471312651d32026d225..292d904c6e166838bafc4671d138a3edb867deda 100644 (file)
@@ -35,7 +35,7 @@ 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('foo');
+  expect(onSelect).lastCalledWith({ value: 'foo' });
 });
 
 it('should call onSearch', () => {
index 0ecd070f31d76b3c65e1d75d209db1abba15228a..0e61c2bcc6e9c3515e077ec03ba2dc58f2c810d2 100644 (file)
@@ -5,6 +5,7 @@ exports[`should render Select 1`] = `
   autofocus={true}
   className="input-super-large"
   clearable={false}
+  escapeClearsValue={false}
   filterOption={[Function]}
   isLoading={false}
   noResultsText="select2.tooShort.2"
index bc84ab6c5122fabc6933ee043cc70c65b05c0427..a8bc7eac879783010ab5fafbc7ff6591f24fb3d7 100644 (file)
   border-bottom-left-radius: 2px;
   border-top-left-radius: 2px;
   border-right: 1px solid rgba(0, 126, 255, 0.24);
-  padding: 1px 5px 3px;
+  padding: 1px 5px;
 }
 
 .Select--multi .Select-value-icon:hover,
index 2f34ccf9fe088aaa7cdcfe1f2ae208264b9153a8..8879f11264fc08062127c8bd89503532151e9074 100644 (file)
@@ -25,7 +25,7 @@ type Option = { label: string; value: string };
 interface Props {
   minimumQueryLength?: number;
   onSearch: (query: string) => Promise<Option[]>;
-  onSelect: (value: string) => void;
+  onSelect: (option: Option) => void;
   renderOption?: (option: Object) => JSX.Element;
 }