aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/components/facet
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2018-08-14 16:12:56 +0200
committerSonarTech <sonartech@sonarsource.com>2018-08-21 20:21:02 +0200
commit63055cd49b4053c4f99949f505f6ce1214cb4135 (patch)
tree77ec3f0ef3410ce84155da6d5af27132cbde8b56 /server/sonar-web/src/main/js/components/facet
parentc9d8fb12afc55512508c55f4026fbad3797c0439 (diff)
downloadsonarqube-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')
-rw-r--r--server/sonar-web/src/main/js/components/facet/FacetItem.tsx21
-rw-r--r--server/sonar-web/src/main/js/components/facet/ListStyleFacet.css39
-rw-r--r--server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx161
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx4
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx50
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap16
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap130
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"