diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-02-22 12:08:16 +0100 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-02-23 16:34:13 +0100 |
commit | 5b3eaeebc6e69dc03d794cc0febcacb48408cf10 (patch) | |
tree | 05912182ccdf41d7c1d2ddf738c106988db52849 /server/sonar-web | |
parent | 1ecbb0ef85da06d61cfda9f19568f3311f795d8e (diff) | |
download | sonarqube-5b3eaeebc6e69dc03d794cc0febcacb48408cf10.tar.gz sonarqube-5b3eaeebc6e69dc03d794cc0febcacb48408cf10.zip |
SONAR-10207 Show a loading spinner in tags selector
Diffstat (limited to 'server/sonar-web')
16 files changed, 131 insertions, 87 deletions
diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index 9830e627e54..f5ec6453bcc 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -20,6 +20,7 @@ import { FacetValue } from '../app/types'; import { getJSON, post, postJSON, RequestData } from '../helpers/request'; import { RawIssue } from '../helpers/issues'; +import throwGlobalError from '../app/utils/throwGlobalError'; export interface IssueResponse { components?: Array<{}>; @@ -117,7 +118,9 @@ export function getIssuesCount(query: RequestData): Promise<any> { export function searchIssueTags( data: { organization?: string; ps?: number; q?: string } = { ps: 100 } ): Promise<string[]> { - return getJSON('/api/issues/tags', data).then(r => r.tags); + return getJSON('/api/issues/tags', data) + .then(r => r.tags) + .catch(throwGlobalError); } export function getIssueChangelog(issue: string): Promise<any> { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx index d6064752070..ddd9a5d54ed 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx @@ -32,7 +32,7 @@ interface Props { } interface State { - searchResult: any[]; + searchResult: string[]; } const LIST_SIZE = 10; @@ -43,7 +43,6 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta componentDidMount() { this.mounted = true; - this.onSearch(''); } componentWillUnmount() { @@ -51,7 +50,7 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta } onSearch = (query: string) => { - getRuleTags({ + return getRuleTags({ q: query, ps: Math.min(this.props.tags.length + LIST_SIZE, 100), organization: this.props.organization @@ -77,13 +76,13 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta render() { return ( <TagsSelector - position={this.props.popupPosition || {}} - tags={this.state.searchResult} - selectedTags={this.props.tags} listSize={LIST_SIZE} onSearch={this.onSearch} onSelect={this.onSelect} onUnselect={this.onUnselect} + position={this.props.popupPosition || {}} + selectedTags={this.props.tags} + tags={this.state.searchResult} /> ); } diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx index 2e2bb774a80..2fdd9fe1f6e 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx +++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx @@ -37,17 +37,29 @@ interface State { const LIST_SIZE = 10; export default class MetaTagsSelector extends React.PureComponent<Props, State> { + mounted = false; state: State = { searchResult: [] }; componentDidMount() { - this.onSearch(''); + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; } onSearch = (query: string) => { - searchProjectTags({ + return searchProjectTags({ q: query, ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100) - }).then(result => this.setState({ searchResult: result.tags }), () => {}); + }).then( + ({ tags }) => { + if (this.mounted) { + this.setState({ searchResult: tags }); + } + }, + () => {} + ); }; onSelect = (tag: string) => { @@ -61,13 +73,13 @@ export default class MetaTagsSelector extends React.PureComponent<Props, State> render() { return ( <TagsSelector - position={this.props.position} - tags={this.state.searchResult} - selectedTags={this.props.selectedTags} listSize={LIST_SIZE} onSearch={this.onSearch} onSelect={this.onSelect} onUnselect={this.onUnselect} + position={this.props.position} + selectedTags={this.props.selectedTags} + tags={this.state.searchResult} /> ); } diff --git a/server/sonar-web/src/main/js/components/common/MultiSelect.tsx b/server/sonar-web/src/main/js/components/common/MultiSelect.tsx index bc235e5a1af..91a68f25fe8 100644 --- a/server/sonar-web/src/main/js/components/common/MultiSelect.tsx +++ b/server/sonar-web/src/main/js/components/common/MultiSelect.tsx @@ -23,21 +23,22 @@ import MultiSelectOption from './MultiSelectOption'; import SearchBox from '../controls/SearchBox'; interface Props { - selectedElements: Array<string>; - elements: Array<string>; + elements: string[]; listSize?: number; - onSearch: (query: string) => void; + onSearch: (query: string) => Promise<void>; onSelect: (item: string) => void; onUnselect: (item: string) => void; - validateSearchInput?: (value: string) => string; placeholder: string; + selectedElements: string[]; + validateSearchInput?: (value: string) => string; } interface State { - query: string; - selectedElements: Array<string>; - unselectedElements: Array<string>; activeIdx: number; + loading: boolean; + query: string; + selectedElements: string[]; + unselectedElements: string[]; } interface DefaultProps { @@ -50,6 +51,7 @@ type PropsWithDefault = Props & DefaultProps; export default class MultiSelect extends React.PureComponent<Props, State> { container?: HTMLDivElement | null; searchInput?: HTMLInputElement | null; + mounted = false; static defaultProps: DefaultProps = { listSize: 10, @@ -59,14 +61,17 @@ export default class MultiSelect extends React.PureComponent<Props, State> { constructor(props: Props) { super(props); this.state = { + activeIdx: 0, + loading: true, query: '', selectedElements: [], - unselectedElements: [], - activeIdx: 0 + unselectedElements: [] }; } componentDidMount() { + this.mounted = true; + this.onSearchQuery(''); this.updateSelectedElements(this.props); this.updateUnselectedElements(this.props as PropsWithDefault); if (this.container) { @@ -96,6 +101,7 @@ export default class MultiSelect extends React.PureComponent<Props, State> { } componentWillUnmount() { + this.mounted = false; if (this.container) { this.container.removeEventListener('keydown', this.handleKeyboard); } @@ -122,14 +128,14 @@ export default class MultiSelect extends React.PureComponent<Props, State> { handleKeyboard = (evt: KeyboardEvent) => { switch (evt.keyCode) { case 40: // down - this.setState(this.selectNextElement); evt.stopPropagation(); evt.preventDefault(); + this.setState(this.selectNextElement); break; case 38: // up - this.setState(this.selectPreviousElement); evt.stopPropagation(); evt.preventDefault(); + this.setState(this.selectPreviousElement); break; case 37: // left case 39: // right @@ -144,8 +150,8 @@ export default class MultiSelect extends React.PureComponent<Props, State> { }; onSearchQuery = (query: string) => { - this.setState({ query, activeIdx: 0 }); - this.props.onSearch(query); + this.setState({ activeIdx: 0, loading: true, query }); + this.props.onSearch(query).then(this.stopLoading, this.stopLoading); }; onSelectItem = (item: string) => { @@ -218,6 +224,12 @@ export default class MultiSelect extends React.PureComponent<Props, State> { } }; + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + toggleSelect = (item: string) => { if (this.props.selectedElements.indexOf(item) === -1) { this.onSelectItem(item); @@ -236,6 +248,7 @@ export default class MultiSelect extends React.PureComponent<Props, State> { <SearchBox autoFocus={true} className="little-spacer-top" + loading={this.state.loading} onChange={this.handleSearchChange} placeholder={this.props.placeholder} value={query} diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx index 13bb1651866..097fd543c36 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx @@ -24,7 +24,7 @@ import MultiSelect from '../MultiSelect'; const props = { selectedElements: ['bar'], elements: [], - onSearch: () => {}, + onSearch: () => Promise.resolve(), onSelect: () => {}, onUnselect: () => {}, placeholder: '' diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap index 15c17fc74ad..69b2384f396 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap @@ -10,6 +10,7 @@ exports[`should render multiselect with selected elements 1`] = ` <SearchBox autoFocus={true} className="little-spacer-top" + loading={true} onChange={[Function]} placeholder="" value="" @@ -40,6 +41,7 @@ exports[`should render multiselect with selected elements 2`] = ` <SearchBox autoFocus={true} className="little-spacer-top" + loading={true} onChange={[Function]} placeholder="" value="" @@ -84,6 +86,7 @@ exports[`should render multiselect with selected elements 3`] = ` <SearchBox autoFocus={true} className="little-spacer-top" + loading={true} onChange={[Function]} placeholder="" value="" @@ -128,6 +131,7 @@ exports[`should render multiselect with selected elements 4`] = ` <SearchBox autoFocus={true} className="little-spacer-top" + loading={true} onChange={[Function]} placeholder="" value="test" diff --git a/server/sonar-web/src/main/js/components/controls/SearchBox.css b/server/sonar-web/src/main/js/components/controls/SearchBox.css index daecb042ac0..7c5d32ecae4 100644 --- a/server/sonar-web/src/main/js/components/controls/SearchBox.css +++ b/server/sonar-web/src/main/js/components/controls/SearchBox.css @@ -85,6 +85,12 @@ transition: color 0.3s ease; } +.search-box > .spinner { + position: absolute; + top: 4px; + left: 5px; +} + .search-box-clear { position: absolute; top: 4px; diff --git a/server/sonar-web/src/main/js/components/controls/SearchBox.tsx b/server/sonar-web/src/main/js/components/controls/SearchBox.tsx index b5b29941883..84329946fed 100644 --- a/server/sonar-web/src/main/js/components/controls/SearchBox.tsx +++ b/server/sonar-web/src/main/js/components/controls/SearchBox.tsx @@ -20,8 +20,9 @@ import * as React from 'react'; import * as classNames from 'classnames'; import { debounce, Cancelable } from 'lodash'; -import SearchIcon from '../icons-components/SearchIcon'; import ClearIcon from '../icons-components/ClearIcon'; +import SearchIcon from '../icons-components/SearchIcon'; +import DeferredSpinner from '../common/DeferredSpinner'; import { ButtonIcon } from '../ui/buttons'; import * as theme from '../../app/theme'; import { translateWithParameters } from '../../helpers/l10n'; @@ -32,6 +33,7 @@ interface Props { className?: string; innerRef?: (node: HTMLInputElement | null) => void; id?: string; + loading?: boolean; minLength?: number; onChange: (value: string) => void; onClick?: React.MouseEventHandler<HTMLInputElement>; @@ -119,7 +121,7 @@ export default class SearchBox extends React.PureComponent<Props, State> { }; render() { - const { minLength } = this.props; + const { loading, minLength } = this.props; const { value } = this.state; const inputClassName = classNames('search-box-input', { @@ -145,7 +147,9 @@ export default class SearchBox extends React.PureComponent<Props, State> { value={value} /> - <SearchIcon className="search-box-magnifier" /> + <DeferredSpinner loading={loading !== undefined ? loading : false}> + <SearchIcon className="search-box-magnifier" /> + </DeferredSpinner> {value && ( <ButtonIcon diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap index d69e12de1e3..25b226be5a2 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap @@ -14,9 +14,14 @@ exports[`renders 1`] = ` type="search" value="foo" /> - <SearchIcon - className="search-box-magnifier" - /> + <DeferredSpinner + loading={false} + timeout={100} + > + <SearchIcon + className="search-box-magnifier" + /> + </DeferredSpinner> <ButtonIcon className="button-tiny search-box-clear" color="#999" diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js index e4ba9ab9968..e40a85b71af 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js @@ -68,7 +68,6 @@ export default class IssueTags extends React.PureComponent { togglePopup={this.toggleSetTags} popup={ <SetIssueTagsPopup - onFail={this.props.onFail} organization={issue.projectOrganization} selectedTags={issue.tags} setTags={this.setTags} diff --git a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTags-test.js.snap b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTags-test.js.snap index 178f8b75e19..fa30aff7c20 100644 --- a/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTags-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/components/__tests__/__snapshots__/IssueTags-test.js.snap @@ -23,7 +23,6 @@ exports[`should open the popup when the button is clicked 2`] = ` isOpen={true} popup={ <SetIssueTagsPopup - onFail={[MockFunction]} organization="foo" selectedTags={ Array [ @@ -59,7 +58,6 @@ exports[`should render with the action 1`] = ` isOpen={false} popup={ <SetIssueTagsPopup - onFail={[MockFunction]} organization="foo" selectedTags={ Array [ diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx index c334412d5c0..7aa3bebba62 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.js +++ b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx @@ -17,74 +17,70 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -//@flow -import React from 'react'; +import * as React from 'react'; import { without } from 'lodash'; +import { BubblePopupPosition } from '../../../components/common/BubblePopup'; import TagsSelector from '../../../components/tags/TagsSelector'; import { searchIssueTags } from '../../../api/issues'; -/*:: -type Props = { - popupPosition?: {}, - onFail: Error => void, - organization: string, - selectedTags: Array<string>, - setTags: (Array<string>) => void -}; -*/ +interface Props { + popupPosition: BubblePopupPosition; + organization: string; + selectedTags: string[]; + setTags: (tags: string[]) => void; +} -/*:: -type State = { - searchResult: Array<string> -}; -*/ +interface State { + searchResult: string[]; +} const LIST_SIZE = 10; -export default class SetIssueTagsPopup extends React.PureComponent { - /*:: mounted: boolean; */ - /*:: props: Props; */ - state /*: State */ = { searchResult: [] }; +export default class SetIssueTagsPopup extends React.PureComponent<Props, State> { + mounted = false; + state: State = { searchResult: [] }; componentDidMount() { this.mounted = true; - this.onSearch(''); } componentWillUnmount() { this.mounted = false; } - onSearch = (query /*: string */) => { - searchIssueTags({ + onSearch = (query: string) => { + return searchIssueTags({ q: query, ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100), organization: this.props.organization - }).then((tags /*: Array<string> */) => { - if (this.mounted) { - this.setState({ searchResult: tags }); - } - }, this.props.onFail); + }).then( + (tags: string[]) => { + if (this.mounted) { + this.setState({ searchResult: tags }); + } + }, + () => {} + ); }; - onSelect = (tag /*: string */) => { + onSelect = (tag: string) => { this.props.setTags([...this.props.selectedTags, tag]); }; - onUnselect = (tag /*: string */) => { + onUnselect = (tag: string) => { this.props.setTags(without(this.props.selectedTags, tag)); }; render() { return ( <TagsSelector - position={this.props.popupPosition} - tags={this.state.searchResult} - selectedTags={this.props.selectedTags} listSize={LIST_SIZE} onSearch={this.onSearch} onSelect={this.onSelect} onUnselect={this.onUnselect} + position={this.props.popupPosition} + selectedTags={this.props.selectedTags} + tags={this.state.searchResult} /> ); } diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.js b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.tsx index d2cdf422054..f66e329ec87 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.js +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.tsx @@ -17,16 +17,16 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import * as React from 'react'; import { shallow } from 'enzyme'; -import React from 'react'; import SetIssueTagsPopup from '../SetIssueTagsPopup'; it('should render tags popup correctly', () => { const element = shallow( <SetIssueTagsPopup - onFail={jest.fn()} organization="foo" - selectedTags="mytag" + popupPosition={{}} + selectedTags={['mytag']} setTags={jest.fn()} /> ); diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.js.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap index 3eee8429326..36d73b1a4cf 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.js.snap +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap @@ -6,7 +6,12 @@ exports[`should render tags popup correctly 1`] = ` onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} - selectedTags="mytag" + position={Object {}} + selectedTags={ + Array [ + "mytag", + ] + } tags={ Array [ "mytag", diff --git a/server/sonar-web/src/main/js/components/tags/TagsSelector.tsx b/server/sonar-web/src/main/js/components/tags/TagsSelector.tsx index 82d42c93710..120f7cdeef5 100644 --- a/server/sonar-web/src/main/js/components/tags/TagsSelector.tsx +++ b/server/sonar-web/src/main/js/components/tags/TagsSelector.tsx @@ -24,29 +24,29 @@ import { translate } from '../../helpers/l10n'; import './TagsList.css'; interface Props { - position: BubblePopupPosition; - tags: string[]; - selectedTags: string[]; listSize: number; - onSearch: (query: string) => void; + onSearch: (query: string) => Promise<void>; onSelect: (item: string) => void; onUnselect: (item: string) => void; + position: BubblePopupPosition; + selectedTags: string[]; + tags: string[]; } export default function TagsSelector(props: Props) { return ( <BubblePopup - position={props.position} - customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300"> + customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300" + position={props.position}> <MultiSelect elements={props.tags} - selectedElements={props.selectedTags} listSize={props.listSize} onSearch={props.onSearch} onSelect={props.onSelect} onUnselect={props.onUnselect} - validateSearchInput={validateTag} placeholder={translate('search.search_for_tags')} + selectedElements={props.selectedTags} + validateSearchInput={validateTag} /> </BubblePopup> ); diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx index 1b5169a2940..f2dd87eda02 100644 --- a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx @@ -22,13 +22,13 @@ import { shallow } from 'enzyme'; import TagsSelector, { validateTag } from '../TagsSelector'; const props = { - position: { right: 0, top: 0 }, listSize: 10, - tags: ['foo', 'bar', 'baz'], - selectedTags: ['bar'], - onSearch: () => {}, + onSearch: () => Promise.resolve(), onSelect: () => {}, - onUnselect: () => {} + onUnselect: () => {}, + position: { right: 0, top: 0 }, + selectedTags: ['bar'], + tags: ['foo', 'bar', 'baz'] }; it('should render with selected tags', () => { @@ -37,7 +37,7 @@ it('should render with selected tags', () => { }); it('should render without tags at all', () => { - expect(shallow(<TagsSelector {...props} tags={[]} selectedTags={[]} />)).toMatchSnapshot(); + expect(shallow(<TagsSelector {...props} selectedTags={[]} tags={[]} />)).toMatchSnapshot(); }); it('should validate tags correctly', () => { |