diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-08-16 14:43:06 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-08-21 20:21:03 +0200 |
commit | 6a2c038752b09413a9c749b3dbfdb408a72def20 (patch) | |
tree | 74f0f3174b434713246c221de4a167cf73c4f3ae /server/sonar-web/src/main/js/components/facet | |
parent | f91cf3ea0050655c715dc20465118a0568d4ec83 (diff) | |
download | sonarqube-6a2c038752b09413a9c749b3dbfdb408a72def20.tar.gz sonarqube-6a2c038752b09413a9c749b3dbfdb408a72def20.zip |
SONAR-6961 load counts for search results (#619)
Diffstat (limited to 'server/sonar-web/src/main/js/components/facet')
5 files changed, 123 insertions, 275 deletions
diff --git a/server/sonar-web/src/main/js/components/facet/FacetItem.tsx b/server/sonar-web/src/main/js/components/facet/FacetItem.tsx index d3f9b6711c3..fb42dba239d 100644 --- a/server/sonar-web/src/main/js/components/facet/FacetItem.tsx +++ b/server/sonar-web/src/main/js/components/facet/FacetItem.tsx @@ -19,14 +19,12 @@ */ import * as React from 'react'; import * as classNames from 'classnames'; -import DeferredSpinner from '../common/DeferredSpinner'; export interface Props { active?: boolean; className?: string; disabled?: boolean; halfWidth?: boolean; - loading?: boolean; name: React.ReactNode; onClick: (x: string, multiple?: boolean) => void; stat?: React.ReactNode; @@ -49,14 +47,6 @@ export default class FacetItem extends React.PureComponent<Props> { }; renderValue() { - if (this.props.loading) { - return ( - <span className="facet-stat"> - <DeferredSpinner /> - </span> - ); - } - if (this.props.stat == null) { return null; } diff --git a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css deleted file mode 100644 index 5dcb7327e36..00000000000 --- a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css +++ /dev/null @@ -1,39 +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. - */ -.list-style-facet-mouse-over-animation::after { - content: ''; - position: absolute; - z-index: 1; - top: 0; - bottom: 0; - left: 0; - width: 0; - background-color: var(--lightBlue); -} - -.list-style-facet-mouse-over-animation:hover::after { - width: 100%; - transition: width 0.5s linear; -} - -.list-style-facet-mouse-over-animation .facet-name, -.list-style-facet-mouse-over-animation .facet-stat { - z-index: 2; -} diff --git a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx index e96394d6701..fc62405a778 100644 --- a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx +++ b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx @@ -19,7 +19,6 @@ */ import * as React from 'react'; import { sortBy, without } from 'lodash'; -import * as classNames from 'classnames'; import FacetBox from './FacetBox'; import FacetHeader from './FacetHeader'; import FacetItem from './FacetItem'; @@ -32,9 +31,13 @@ import { Paging } from '../../app/types'; import SearchBox from '../controls/SearchBox'; import ListFooter from '../controls/ListFooter'; import { formatMeasure } from '../../helpers/measures'; -import MouseOverHandler from '../controls/MouseOverHandler'; import { queriesEqual, RawQuery } from '../../helpers/query'; -import './ListStyleFacet.css'; + +interface SearchResponse<S> { + maxResults?: boolean; + results: S[]; + paging?: Paging; +} export interface Props<S> { className?: string; @@ -43,17 +46,14 @@ export interface Props<S> { getFacetItemText: (item: string) => string; getSearchResultKey: (result: S) => string; getSearchResultText: (result: S) => string; - loadSearchResultCount?: (result: S) => Promise<number>; - maxInitialItems?: number; - maxItems?: number; - minSearchLength?: number; + loadSearchResultCount?: (result: S[]) => Promise<{ [x: string]: number }>; + maxInitialItems: number; + maxItems: number; + minSearchLength: number; onChange: (changes: { [x: string]: string | string[] }) => void; onClear?: () => void; onItemClick?: (itemValue: string, multiple: boolean) => void; - onSearch: ( - query: string, - page?: number - ) => Promise<{ maxResults?: boolean; results: S[]; paging?: Paging }>; + onSearch: (query: string, page?: number) => Promise<SearchResponse<S>>; onToggle: (property: string) => void; open: boolean; property: string; @@ -74,7 +74,6 @@ interface State<S> { searchPaging?: Paging; searchResults?: S[]; searchResultsCounts: { [key: string]: number }; - searchResultsCountLoading: { [key: string]: boolean }; showFullList: boolean; } @@ -83,7 +82,8 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S static defaultProps = { maxInitialItems: 15, - maxItems: 100 + maxItems: 100, + minSearchLength: 2 }; state: State<S> = { @@ -91,7 +91,6 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S query: '', searching: false, searchResultsCounts: {}, - searchResultsCountLoading: {}, showFullList: false }; @@ -100,16 +99,6 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S } componentDidUpdate(prevProps: Props<S>) { - // always remember issue counts from `stats` - if (prevProps.stats !== this.props.stats) { - this.setState(state => ({ - searchResultsCounts: { - ...state.searchResultsCounts, - ...this.props.stats - } - })); - } - if (!prevProps.open && this.props.open) { // focus search field *only* if it was manually open this.setState({ autoFocus: true }); @@ -124,12 +113,11 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S searchResults: undefined, searching: false, searchResultsCounts: {}, - searchResultsCountLoading: {}, showFullList: false }); } else if ( prevProps.stats !== this.props.stats && - Object.keys(this.props.stats || {}).length < this.props.maxInitialItems! + Object.keys(this.props.stats || {}).length < this.props.maxInitialItems ) { // show limited list if `stats` changed and there are less than 15 items this.setState({ showFullList: false }); @@ -175,18 +163,23 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S }; search = (query: string) => { - if (query.length >= 2) { + if (query.length >= this.props.minSearchLength) { this.setState({ query, searching: true }); - this.props.onSearch(query).then(({ maxResults, paging, results }) => { - if (this.mounted) { - this.setState({ - searching: false, - searchMaxResults: maxResults, - searchResults: results, - searchPaging: paging - }); - } - }, this.stopSearching); + this.props + .onSearch(query) + .then(this.loadCountsForSearchResults) + .then(({ maxResults, paging, results, stats }) => { + if (this.mounted) { + this.setState(state => ({ + searching: false, + searchMaxResults: maxResults, + searchResults: results, + searchPaging: paging, + searchResultsCounts: { ...state.searchResultsCounts, ...stats } + })); + } + }) + .catch(this.stopSearching); } else { this.setState({ query, searching: false, searchResults: [] }); } @@ -196,56 +189,33 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S const { query, searchPaging, searchResults } = this.state; if (query && searchResults && searchPaging) { this.setState({ searching: true }); - this.props.onSearch(query, searchPaging.pageIndex + 1).then(({ paging, results }) => { - if (this.mounted) { - this.setState({ - searching: false, - searchResults: [...searchResults, ...results], - searchPaging: paging - }); - } - }, this.stopSearching); - } - }; - - handleSearchResultMouseOver = (result: S) => { - if ( - this.props.loadSearchResultCount && - this.state.searchResultsCounts[this.props.getSearchResultKey(result)] === undefined - ) { - this.setState(state => ({ - searchResultsCountLoading: { - ...state.searchResultsCountLoading, - [this.props.getSearchResultKey(result)]: true - } - })); - - this.props.loadSearchResultCount(result).then( - count => { - if (this.mounted) { - this.setState(state => ({ - searchResultsCounts: { - ...state.searchResultsCounts, - [this.props.getSearchResultKey(result)]: count - }, - searchResultsCountLoading: { - ...state.searchResultsCountLoading, - [this.props.getSearchResultKey(result)]: false - } - })); - } - }, - () => { + this.props + .onSearch(query, searchPaging.pageIndex + 1) + .then(this.loadCountsForSearchResults) + .then(({ paging, results, stats }) => { if (this.mounted) { this.setState(state => ({ - searchResultsCountLoading: { - ...state.searchResultsCountLoading, - [this.props.getSearchResultKey(result)]: false - } + searching: false, + searchResults: [...searchResults, ...results], + searchPaging: paging, + searchResultsCounts: { ...state.searchResultsCounts, ...stats } })); } - } - ); + }) + .catch(this.stopSearching); + } + }; + + loadCountsForSearchResults = (response: SearchResponse<S>) => { + const { loadSearchResultCount = () => Promise.resolve({}) } = this.props; + const resultsToLoad = response.results.filter(result => { + const key = this.props.getSearchResultKey(result); + return this.getStat(key) === undefined && this.state.searchResultsCounts[key] === undefined; + }); + if (resultsToLoad.length > 0) { + return loadSearchResultCount(resultsToLoad).then(stats => ({ ...response, stats })); + } else { + return { ...response, stats: {} }; } }; @@ -285,12 +255,12 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S // limit the number of items to this.props.maxInitialItems, // but make sure all (in other words, the last) selected items are displayed const lastSelectedIndex = this.getLastActiveIndex(sortedItems); - const countToDisplay = Math.max(this.props.maxInitialItems!, lastSelectedIndex + 1); + const countToDisplay = Math.max(this.props.maxInitialItems, lastSelectedIndex + 1); const limitedList = this.state.showFullList ? sortedItems : sortedItems.slice(0, countToDisplay); - const mightHaveMoreResults = sortedItems.length >= this.props.maxItems!; + const mightHaveMoreResults = sortedItems.length >= this.props.maxItems; return ( <> @@ -328,14 +298,12 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S return null; } - const { minSearchLength = 2 } = this.props; - return ( <SearchBox autoFocus={this.state.autoFocus} className="little-spacer-top spacer-bottom" loading={this.state.searching} - minLength={minSearchLength} + minLength={this.props.minSearchLength} onChange={this.search} placeholder={this.props.searchPlaceholder} value={this.state.query} @@ -381,31 +349,13 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S renderSearchResult(result: S) { const key = this.props.getSearchResultKey(result); const active = this.props.values.includes(key); - - // default to 0 if we're sure there are not more results - const isFacetExhaustive = Object.keys(this.props.stats || {}).length < this.props.maxItems!; - - let stat: number | undefined = this.getStat(key); - let disabled = isFacetExhaustive && !active && stat === 0; - if (stat === undefined) { - stat = this.state.searchResultsCounts[key]; - disabled = false; // do not disable facet if the count was requested after mouse over - } - if (stat === undefined && isFacetExhaustive) { - stat = 0; - disabled = !active; - } - - const loading = this.state.searchResultsCountLoading[key]; - const canBeLoaded = - !loading && this.props.loadSearchResultCount !== undefined && stat === undefined; - - const facetItem = ( + const stat = this.getStat(key) || this.state.searchResultsCounts[key]; + const disabled = !active && stat === 0; + return ( <FacetItem active={active} - className={classNames({ 'list-style-facet-mouse-over-animation': canBeLoaded })} disabled={disabled} - loading={loading} + key={key} name={this.props.renderSearchResult(result, this.state.query)} onClick={this.handleItemClick} stat={formatFacetStat(stat)} @@ -413,18 +363,6 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S value={key} /> ); - - return ( - <React.Fragment key={key}> - {canBeLoaded ? ( - <MouseOverHandler delay={500} onOver={() => this.handleSearchResultMouseOver(result)}> - {facetItem} - </MouseOverHandler> - ) : ( - facetItem - )} - </React.Fragment> - ); } render() { diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx b/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx index 553f1ca7796..d7290b74f44 100644 --- a/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx +++ b/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx @@ -76,19 +76,22 @@ it('should search', async () => { results: ['d', 'e'], paging: { pageIndex: 1, pageSize: 2, total: 3 } }); - const wrapper = shallowRender({ onSearch }); + const loadSearchResultCount = jest.fn().mockResolvedValue({ d: 7, e: 3 }); + const wrapper = shallowRender({ loadSearchResultCount, onSearch }); // search wrapper.find('SearchBox').prop<Function>('onChange')('query'); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); expect(onSearch).lastCalledWith('query'); + expect(loadSearchResultCount).lastCalledWith(['d', 'e']); // load more results onSearch.mockResolvedValue({ results: ['f'], paging: { pageIndex: 2, pageSize: 2, total: 3 } }); + loadSearchResultCount.mockResolvedValue({ f: 5 }); wrapper.find('ListFooter').prop<Function>('loadMore')(); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); @@ -96,10 +99,12 @@ it('should search', async () => { // clear search onSearch.mockClear(); + loadSearchResultCount.mockClear(); wrapper.find('SearchBox').prop<Function>('onChange')(''); await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); expect(onSearch).not.toBeCalled(); + expect(loadSearchResultCount).not.toBeCalled(); // search for no results onSearch.mockResolvedValue({ results: [], paging: { pageIndex: 1, pageSize: 2, total: 0 } }); @@ -107,6 +112,7 @@ it('should search', async () => { await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); expect(onSearch).lastCalledWith('blabla'); + expect(loadSearchResultCount).not.toBeCalled(); // search fails onSearch.mockRejectedValue(undefined); @@ -114,6 +120,7 @@ it('should search', async () => { await waitAndUpdate(wrapper); expect(wrapper).toMatchSnapshot(); // should render previous results expect(onSearch).lastCalledWith('blabla'); + expect(loadSearchResultCount).not.toBeCalled(); }); it('should limit the number of items', () => { @@ -164,33 +171,6 @@ it('should collapse list when new stats have few results', () => { expect(wrapper.state('showFullList')).toBe(false); }); -it('should load count on mouse over', async () => { - const loadSearchResultCount = jest.fn().mockResolvedValue(5); - const onSearch = jest.fn().mockResolvedValue({ - results: ['d', 'e'], - paging: { pageIndex: 1, pageSize: 2, total: 3 } - }); - const wrapper = shallowRender({ loadSearchResultCount, maxItems: 1, onSearch }); - - // search - wrapper.find('SearchBox').prop<Function>('onChange')('query'); - await waitAndUpdate(wrapper); - - expect(firstFacetItem().prop('stat')).toBeUndefined(); - wrapper - .find('MouseOverHandler') - .first() - .prop<Function>('onOver')(); - expect(loadSearchResultCount).toBeCalledWith('d'); - await waitAndUpdate(wrapper); - - expect(firstFacetItem().prop('stat')).toBe('5'); - - function firstFacetItem() { - return wrapper.find('FacetItem').first(); - } -}); - function shallowRender(props: Partial<Props<string>> = {}) { return shallow( <ListStyleFacet @@ -223,6 +203,5 @@ function checkInitialState(wrapper: ShallowWrapper) { expect(wrapper.state('searchResults')).toBe(undefined); expect(wrapper.state('searching')).toBe(false); expect(wrapper.state('searchResultsCounts')).toEqual({}); - expect(wrapper.state('searchResultsCountLoading')).toEqual({}); expect(wrapper.state('showFullList')).toBe(false); } diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap index 0ec32826fd8..c5953ef7031 100644 --- a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap @@ -105,38 +105,30 @@ exports[`should search 1`] = ` /> <React.Fragment> <FacetItemsList> - <React.Fragment + <FacetItem + active={false} + disabled={false} + halfWidth={false} key="d" - > - <FacetItem - active={false} - className="" - disabled={true} - halfWidth={false} - loading={false} - name="d" - onClick={[Function]} - stat={0} - tooltip="d" - value="d" - /> - </React.Fragment> - <React.Fragment + loading={false} + name="d" + onClick={[Function]} + stat="7" + tooltip="d" + value="d" + /> + <FacetItem + active={false} + disabled={false} + halfWidth={false} key="e" - > - <FacetItem - active={false} - className="" - disabled={true} - halfWidth={false} - loading={false} - name="e" - onClick={[Function]} - stat={0} - tooltip="e" - value="e" - /> - </React.Fragment> + loading={false} + name="e" + onClick={[Function]} + stat="3" + tooltip="e" + value="e" + /> </FacetItemsList> <ListFooter className="spacer-bottom" @@ -181,54 +173,42 @@ exports[`should search 2`] = ` /> <React.Fragment> <FacetItemsList> - <React.Fragment + <FacetItem + active={false} + disabled={false} + halfWidth={false} key="d" - > - <FacetItem - active={false} - className="" - disabled={true} - halfWidth={false} - loading={false} - name="d" - onClick={[Function]} - stat={0} - tooltip="d" - value="d" - /> - </React.Fragment> - <React.Fragment + loading={false} + name="d" + onClick={[Function]} + stat="7" + tooltip="d" + value="d" + /> + <FacetItem + active={false} + disabled={false} + halfWidth={false} key="e" - > - <FacetItem - active={false} - className="" - disabled={true} - halfWidth={false} - loading={false} - name="e" - onClick={[Function]} - stat={0} - tooltip="e" - value="e" - /> - </React.Fragment> - <React.Fragment + loading={false} + name="e" + onClick={[Function]} + stat="3" + tooltip="e" + value="e" + /> + <FacetItem + active={false} + disabled={false} + halfWidth={false} key="f" - > - <FacetItem - active={false} - className="" - disabled={true} - halfWidth={false} - loading={false} - name="f" - onClick={[Function]} - stat={0} - tooltip="f" - value="f" - /> - </React.Fragment> + loading={false} + name="f" + onClick={[Function]} + stat="5" + tooltip="f" + value="f" + /> </FacetItemsList> <ListFooter className="spacer-bottom" |