From: Grégoire Aubert Date: Thu, 22 Feb 2018 11:08:16 +0000 (+0100) Subject: SONAR-10207 Show a loading spinner in tags selector X-Git-Tag: 7.5~1633 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=5b3eaeebc6e69dc03d794cc0febcacb48408cf10;p=sonarqube.git SONAR-10207 Show a loading spinner in tags selector --- 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 { export function searchIssueTags( data: { organization?: string; ps?: number; q?: string } = { ps: 100 } ): Promise { - 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 { 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 { - 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 ); } 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 { + 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 render() { return ( ); } 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; - elements: Array; + elements: string[]; listSize?: number; - onSearch: (query: string) => void; + onSearch: (query: string) => Promise; 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; - unselectedElements: Array; 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 { container?: HTMLDivElement | null; searchInput?: HTMLInputElement | null; + mounted = false; static defaultProps: DefaultProps = { listSize: 10, @@ -59,14 +61,17 @@ export default class MultiSelect extends React.PureComponent { 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 { } componentWillUnmount() { + this.mounted = false; if (this.container) { this.container.removeEventListener('keydown', this.handleKeyboard); } @@ -122,14 +128,14 @@ export default class MultiSelect extends React.PureComponent { 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 { }; 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 { } }; + 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 { {}, + 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`] = ` .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; @@ -119,7 +121,7 @@ export default class SearchBox extends React.PureComponent { }; 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 { value={value} /> - + + + {value && ( - + + + void, - organization: string, - selectedTags: Array, - setTags: (Array) => void -}; -*/ - -/*:: -type State = { - searchResult: Array -}; -*/ - -const LIST_SIZE = 10; - -export default class SetIssueTagsPopup extends React.PureComponent { - /*:: mounted: boolean; */ - /*:: props: Props; */ - state /*: State */ = { searchResult: [] }; - - componentDidMount() { - this.mounted = true; - this.onSearch(''); - } - - componentWillUnmount() { - this.mounted = false; - } - - onSearch = (query /*: string */) => { - searchIssueTags({ - q: query, - ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100), - organization: this.props.organization - }).then((tags /*: Array */) => { - if (this.mounted) { - this.setState({ searchResult: tags }); - } - }, this.props.onFail); - }; - - onSelect = (tag /*: string */) => { - this.props.setTags([...this.props.selectedTags, tag]); - }; - - onUnselect = (tag /*: string */) => { - this.props.setTags(without(this.props.selectedTags, tag)); - }; - - render() { - return ( - - ); - } -} diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx new file mode 100644 index 00000000000..7aa3bebba62 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx @@ -0,0 +1,87 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * 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'; + +interface Props { + popupPosition: BubblePopupPosition; + organization: string; + selectedTags: string[]; + setTags: (tags: string[]) => void; +} + +interface State { + searchResult: string[]; +} + +const LIST_SIZE = 10; + +export default class SetIssueTagsPopup extends React.PureComponent { + mounted = false; + state: State = { searchResult: [] }; + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + onSearch = (query: string) => { + return searchIssueTags({ + q: query, + ps: Math.min(this.props.selectedTags.length - 1 + LIST_SIZE, 100), + organization: this.props.organization + }).then( + (tags: string[]) => { + if (this.mounted) { + this.setState({ searchResult: tags }); + } + }, + () => {} + ); + }; + + onSelect = (tag: string) => { + this.props.setTags([...this.props.selectedTags, tag]); + }; + + onUnselect = (tag: string) => { + this.props.setTags(without(this.props.selectedTags, tag)); + }; + + render() { + return ( + + ); + } +} 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.js deleted file mode 100644 index d2cdf422054..00000000000 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 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 React from 'react'; -import SetIssueTagsPopup from '../SetIssueTagsPopup'; - -it('should render tags popup correctly', () => { - const element = shallow( - - ); - element.setState({ searchResult: ['mytag', 'test', 'second'] }); - expect(element).toMatchSnapshot(); -}); diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.tsx b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.tsx new file mode 100644 index 00000000000..f66e329ec87 --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/SetIssueTagsPopup-test.tsx @@ -0,0 +1,35 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import { shallow } from 'enzyme'; +import SetIssueTagsPopup from '../SetIssueTagsPopup'; + +it('should render tags popup correctly', () => { + const element = shallow( + + ); + element.setState({ searchResult: ['mytag', 'test', 'second'] }); + expect(element).toMatchSnapshot(); +}); 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.js.snap deleted file mode 100644 index 3eee8429326..00000000000 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.js.snap +++ /dev/null @@ -1,18 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render tags popup correctly 1`] = ` - -`; diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap new file mode 100644 index 00000000000..36d73b1a4cf --- /dev/null +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render tags popup correctly 1`] = ` + +`; 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; onSelect: (item: string) => void; onUnselect: (item: string) => void; + position: BubblePopupPosition; + selectedTags: string[]; + tags: string[]; } export default function TagsSelector(props: Props) { return ( + customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300" + position={props.position}> ); 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()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); it('should validate tags correctly', () => {