diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-02-26 16:49:40 +0100 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-02-27 14:44:23 +0100 |
commit | 2bc87f33684239adbfa746db78ca73290688adaa (patch) | |
tree | e4f04535c3733d28f00da796abfe9e6143a59805 | |
parent | 2947082abde85a0e16ce65da155e1e9d9d7cb78d (diff) | |
download | sonarqube-2bc87f33684239adbfa746db78ca73290688adaa.tar.gz sonarqube-2bc87f33684239adbfa746db78ca73290688adaa.zip |
SONAR-10047 Use SearchSelect for tags in Bulk Issue Change
13 files changed, 107 insertions, 71 deletions
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx index 9ca37cb5de4..deb1a36043a 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx @@ -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) => ( <> diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js index b9f7ef9014f..e8f9b22fd0c 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.js @@ -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]} /> ); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js index a2d1685f607..3034677596f 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.js @@ -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 */) { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js index fba2560cc83..3083a35b69b 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.js @@ -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 */ { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js index 18704e1f4ff..b30780fa363 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.js @@ -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 */ { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js index 51df80106cc..8c62b5d4801 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.js @@ -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 */ { diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js index a2f956dc4a6..815472708ac 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.js @@ -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'] }); }); diff --git a/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx b/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx index 22b1630f1a0..1712ac8c4f0 100644 --- a/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx +++ b/server/sonar-web/src/main/js/components/controls/SearchSelect.tsx @@ -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} diff --git a/server/sonar-web/src/main/js/components/controls/Select.tsx b/server/sonar-web/src/main/js/components/controls/Select.tsx index f8886578b8a..04e49151b88 100644 --- a/server/sonar-web/src/main/js/components/controls/Select.tsx +++ b/server/sonar-web/src/main/js/components/controls/Select.tsx @@ -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} /> ); } 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 index 34d964549b4..292d904c6e1 100644 --- 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 @@ -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', () => { 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 index 0ecd070f31d..0e61c2bcc6e 100644 --- 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 @@ -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" diff --git a/server/sonar-web/src/main/js/components/controls/react-select.css b/server/sonar-web/src/main/js/components/controls/react-select.css index bc84ab6c512..a8bc7eac879 100644 --- a/server/sonar-web/src/main/js/components/controls/react-select.css +++ b/server/sonar-web/src/main/js/components/controls/react-select.css @@ -417,7 +417,7 @@ 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, diff --git a/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx b/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx index 2f34ccf9fe0..8879f11264f 100644 --- a/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx +++ b/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx @@ -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; } |