From 2bc87f33684239adbfa746db78ca73290688adaa Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Mon, 26 Feb 2018 16:49:40 +0100 Subject: [PATCH] SONAR-10047 Use SearchSelect for tags in Bulk Issue Change --- .../apps/coding-rules/components/TagFacet.tsx | 9 +- .../apps/issues/components/BulkChangeModal.js | 102 ++++++++++-------- .../js/apps/issues/sidebar/AssigneeFacet.js | 4 +- .../js/apps/issues/sidebar/ProjectFacet.js | 4 +- .../main/js/apps/issues/sidebar/RuleFacet.js | 4 +- .../main/js/apps/issues/sidebar/TagFacet.js | 4 +- .../sidebar/__tests__/AssigneeFacet-test.js | 2 +- .../js/components/controls/SearchSelect.tsx | 40 +++++-- .../main/js/components/controls/Select.tsx | 2 +- .../controls/__tests__/SearchSelect-test.tsx | 2 +- .../__snapshots__/SearchSelect-test.tsx.snap | 1 + .../js/components/controls/react-select.css | 2 +- .../main/js/components/facet/FacetFooter.tsx | 2 +- 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 { - 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, // used for initial loading of issues loading: boolean, paging?: Paging, // used when submitting a form submitting: boolean, - tags?: Array, // form fields - addTags?: Array, - assignee?: string, + addTags?: Array<{ label: string, value: string }>, + assignee?: { avatar?: string, label: string, value: string }, comment?: string, notifications?: boolean, organization?: string, - removeTags?: Array, + 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 => } + options={options} searchable={false} value={this.state.severity} valueRenderer={option => } @@ -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 = ( - ); 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; - 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 { 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 { } } ); + }; - 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 { handleFilterOption = () => true; render() { + const Component = this.props.canCreate ? Creatable : Select; return ( -