diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-08-14 16:12:56 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-08-21 20:21:02 +0200 |
commit | 63055cd49b4053c4f99949f505f6ce1214cb4135 (patch) | |
tree | 77ec3f0ef3410ce84155da6d5af27132cbde8b56 /server/sonar-web/src/main/js/components/facet | |
parent | c9d8fb12afc55512508c55f4026fbad3797c0439 (diff) | |
download | sonarqube-63055cd49b4053c4f99949f505f6ce1214cb4135.tar.gz sonarqube-63055cd49b4053c4f99949f505f6ce1214cb4135.zip |
SONAR-6961 Add issue counts to search in rule facet on issue page (#612)
Diffstat (limited to 'server/sonar-web/src/main/js/components/facet')
7 files changed, 308 insertions, 113 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 70beec9c901..d3f9b6711c3 100644 --- a/server/sonar-web/src/main/js/components/facet/FacetItem.tsx +++ b/server/sonar-web/src/main/js/components/facet/FacetItem.tsx @@ -19,6 +19,7 @@ */ import * as React from 'react'; import * as classNames from 'classnames'; +import DeferredSpinner from '../common/DeferredSpinner'; export interface Props { active?: boolean; @@ -47,6 +48,22 @@ export default class FacetItem extends React.PureComponent<Props> { this.props.onClick(this.props.value, event.ctrlKey || event.metaKey); }; + renderValue() { + if (this.props.loading) { + return ( + <span className="facet-stat"> + <DeferredSpinner /> + </span> + ); + } + + if (this.props.stat == null) { + return null; + } + + return <span className="facet-stat">{this.props.stat}</span>; + } + render() { const { name } = this.props; const className = classNames('search-navigator-facet', this.props.className, { @@ -57,7 +74,7 @@ export default class FacetItem extends React.PureComponent<Props> { return this.props.disabled ? ( <span className={className} data-facet={this.props.value} title={this.props.tooltip}> <span className="facet-name">{name}</span> - {this.props.stat != null && <span className="facet-stat">{this.props.stat}</span>} + {this.renderValue()} </span> ) : ( <a @@ -67,7 +84,7 @@ export default class FacetItem extends React.PureComponent<Props> { onClick={this.handleClick} title={this.props.tooltip}> <span className="facet-name">{name}</span> - {this.props.stat != null && <span className="facet-stat">{this.props.stat}</span>} + {this.renderValue()} </a> ); } diff --git a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css new file mode 100644 index 00000000000..5dcb7327e36 --- /dev/null +++ b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.css @@ -0,0 +1,39 @@ +/* + * 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 d941c3c17df..e96394d6701 100644 --- a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx +++ b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx @@ -19,6 +19,7 @@ */ 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'; @@ -31,18 +32,24 @@ 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'; export interface Props<S> { + className?: string; facetHeader: string; fetching: boolean; getFacetItemText: (item: string) => string; getSearchResultKey: (result: S) => string; getSearchResultText: (result: S) => string; - loading?: boolean; + loadSearchResultCount?: (result: S) => Promise<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 @@ -50,9 +57,11 @@ export interface Props<S> { onToggle: (property: string) => void; open: boolean; property: string; + query?: RawQuery; renderFacetItem: (item: string) => React.ReactNode; renderSearchResult: (result: S, query: string) => React.ReactNode; searchPlaceholder: string; + getSortedItems?: () => string[]; stats: { [x: string]: number } | undefined; values: string[]; } @@ -64,6 +73,8 @@ interface State<S> { searchMaxResults?: boolean; searchPaging?: Paging; searchResults?: S[]; + searchResultsCounts: { [key: string]: number }; + searchResultsCountLoading: { [key: string]: boolean }; showFullList: boolean; } @@ -79,6 +90,8 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S autoFocus: false, query: '', searching: false, + searchResultsCounts: {}, + searchResultsCountLoading: {}, showFullList: false }; @@ -87,16 +100,31 @@ 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 }); - } else if (prevProps.open && !this.props.open) { - // reset state when closing the facet + } else if ( + (prevProps.open && !this.props.open) || + !queriesEqual(prevProps.query || {}, this.props.query || {}) + ) { + // reset state when closing the facet, or when query changes this.setState({ query: '', searchMaxResults: undefined, searchResults: undefined, searching: false, + searchResultsCounts: {}, + searchResultsCountLoading: {}, showFullList: false }); } else if ( @@ -113,16 +141,20 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S } handleItemClick = (itemValue: string, multiple: boolean) => { - const { values } = this.props; - if (multiple) { - const newValue = sortBy( - values.includes(itemValue) ? without(values, itemValue) : [...values, itemValue] - ); - this.props.onChange({ [this.props.property]: newValue }); + if (this.props.onItemClick) { + this.props.onItemClick(itemValue, multiple); } else { - this.props.onChange({ - [this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue] - }); + const { values } = this.props; + if (multiple) { + const newValue = sortBy( + values.includes(itemValue) ? without(values, itemValue) : [...values, itemValue] + ); + this.props.onChange({ [this.props.property]: newValue }); + } else { + this.props.onChange({ + [this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue] + }); + } } }; @@ -131,7 +163,9 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S }; handleClear = () => { - this.props.onChange({ [this.props.property]: [] }); + if (this.props.onClear) { + this.props.onClear(); + } else this.props.onChange({ [this.props.property]: [] }); }; stopSearching = () => { @@ -174,10 +208,50 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S } }; - getStat(item: string, zeroIfAbsent = false) { + 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 + } + })); + } + }, + () => { + if (this.mounted) { + this.setState(state => ({ + searchResultsCountLoading: { + ...state.searchResultsCountLoading, + [this.props.getSearchResultKey(result)]: false + } + })); + } + } + ); + } + }; + + getStat(item: string) { const { stats } = this.props; - const defaultValue = zeroIfAbsent ? 0 : undefined; - return stats && stats[item] !== undefined ? stats && stats[item] : defaultValue; + return stats && stats[item] !== undefined ? stats && stats[item] : undefined; } showFullList = () => { @@ -204,11 +278,9 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S return null; } - const sortedItems = sortBy( - Object.keys(stats), - key => -stats[key], - key => this.props.getFacetItemText(key) - ); + const sortedItems = this.props.getSortedItems + ? this.props.getSortedItems() + : sortBy(Object.keys(stats), key => -stats[key], key => this.props.getFacetItemText(key)); // limit the number of items to this.props.maxInitialItems, // but make sure all (in other words, the last) selected items are displayed @@ -227,7 +299,6 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S <FacetItem active={this.props.values.includes(item)} key={item} - loading={this.props.loading} name={this.props.renderFacetItem(item)} onClick={this.handleItemClick} stat={formatFacetStat(this.getStat(item))} @@ -313,14 +384,28 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S // default to 0 if we're sure there are not more results const isFacetExhaustive = Object.keys(this.props.stats || {}).length < this.props.maxItems!; - const stat = this.getStat(key, isFacetExhaustive); - return ( + 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 = ( <FacetItem active={active} - disabled={!active && stat === 0} - key={key} - loading={this.props.loading} + className={classNames({ 'list-style-facet-mouse-over-animation': canBeLoaded })} + disabled={disabled} + loading={loading} name={this.props.renderSearchResult(result, this.state.query)} onClick={this.handleItemClick} stat={formatFacetStat(stat)} @@ -328,13 +413,29 @@ 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() { const { stats = {} } = this.props; + const { query, searching, searchResults } = this.state; const values = this.props.values.map(item => this.props.getFacetItemText(item)); + const loadingResults = + query !== '' && searching && (searchResults === undefined || searchResults.length === 0); + const showList = !query || loadingResults; return ( - <FacetBox property={this.props.property}> + <FacetBox className={this.props.className} property={this.props.property}> <FacetHeader name={this.props.facetHeader} onClear={this.handleClear} @@ -347,9 +448,7 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S {this.props.open && ( <> {this.renderSearch()} - {this.state.query && this.state.searchResults !== undefined - ? this.renderSearchResults() - : this.renderList()} + {showList ? this.renderList() : this.renderSearchResults()} <MultipleSelectionHint options={Object.keys(stats).length} values={values.length} /> </> )} diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx b/server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx index ef75f1bd098..bc465a09cd4 100644 --- a/server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx +++ b/server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx @@ -34,10 +34,6 @@ it('should render stat', () => { expect(renderFacetItem({ stat: '13' })).toMatchSnapshot(); }); -it('should loading stat', () => { - expect(renderFacetItem({ loading: true })).toMatchSnapshot(); -}); - it('should render disabled', () => { expect(renderFacetItem({ disabled: true })).toMatchSnapshot(); }); 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 c9c4180d1ea..553f1ca7796 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 @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import ListStyleFacet, { Props } from '../ListStyleFacet'; import { waitAndUpdate } from '../../../helpers/testUtils'; @@ -146,10 +146,14 @@ it('should reset state when closes', () => { }); wrapper.setProps({ open: false }); - expect(wrapper.state('query')).toBe(''); - expect(wrapper.state('searchResults')).toBe(undefined); - expect(wrapper.state('searching')).toBe(false); - expect(wrapper.state('showFullList')).toBe(false); + checkInitialState(wrapper); +}); + +it('should reset search when query changes', () => { + const wrapper = shallowRender({ query: { a: ['foo'] } }); + wrapper.setState({ query: 'foo', searchResults: ['foo'], searchResultsCounts: { foo: 3 } }); + wrapper.setProps({ query: { a: ['foo'], b: ['bar'] } }); + checkInitialState(wrapper); }); it('should collapse list when new stats have few results', () => { @@ -160,6 +164,33 @@ 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 @@ -186,3 +217,12 @@ function shallowRender(props: Partial<Props<string>> = {}) { function identity(str: string) { return str; } + +function checkInitialState(wrapper: ShallowWrapper) { + expect(wrapper.state('query')).toBe(''); + 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__/FacetItem-test.tsx.snap b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap index eaa0c2cf53c..4594bbe113d 100644 --- a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap @@ -1,21 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should loading stat 1`] = ` -<a - className="search-navigator-facet" - data-facet="bar" - href="#" - onClick={[Function]} - title="foo" -> - <span - className="facet-name" - > - foo - </span> -</a> -`; - exports[`should render active 1`] = ` <a className="search-navigator-facet active" 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 82fab9ce2f4..0ec32826fd8 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,30 +105,38 @@ exports[`should search 1`] = ` /> <React.Fragment> <FacetItemsList> - <FacetItem - active={false} - disabled={true} - halfWidth={false} + <React.Fragment key="d" - loading={false} - name="d" - onClick={[Function]} - stat={0} - tooltip="d" - value="d" - /> - <FacetItem - active={false} - disabled={true} - halfWidth={false} + > + <FacetItem + active={false} + className="" + disabled={true} + halfWidth={false} + loading={false} + name="d" + onClick={[Function]} + stat={0} + tooltip="d" + value="d" + /> + </React.Fragment> + <React.Fragment key="e" - loading={false} - name="e" - onClick={[Function]} - stat={0} - tooltip="e" - value="e" - /> + > + <FacetItem + active={false} + className="" + disabled={true} + halfWidth={false} + loading={false} + name="e" + onClick={[Function]} + stat={0} + tooltip="e" + value="e" + /> + </React.Fragment> </FacetItemsList> <ListFooter className="spacer-bottom" @@ -173,42 +181,54 @@ exports[`should search 2`] = ` /> <React.Fragment> <FacetItemsList> - <FacetItem - active={false} - disabled={true} - halfWidth={false} + <React.Fragment key="d" - loading={false} - name="d" - onClick={[Function]} - stat={0} - tooltip="d" - value="d" - /> - <FacetItem - active={false} - disabled={true} - halfWidth={false} + > + <FacetItem + active={false} + className="" + disabled={true} + halfWidth={false} + loading={false} + name="d" + onClick={[Function]} + stat={0} + tooltip="d" + value="d" + /> + </React.Fragment> + <React.Fragment key="e" - loading={false} - name="e" - onClick={[Function]} - stat={0} - tooltip="e" - value="e" - /> - <FacetItem - active={false} - disabled={true} - halfWidth={false} + > + <FacetItem + active={false} + className="" + disabled={true} + halfWidth={false} + loading={false} + name="e" + onClick={[Function]} + stat={0} + tooltip="e" + value="e" + /> + </React.Fragment> + <React.Fragment key="f" - loading={false} - name="f" - onClick={[Function]} - stat={0} - tooltip="f" - value="f" - /> + > + <FacetItem + active={false} + className="" + disabled={true} + halfWidth={false} + loading={false} + name="f" + onClick={[Function]} + stat={0} + tooltip="f" + value="f" + /> + </React.Fragment> </FacetItemsList> <ListFooter className="spacer-bottom" |