@@ -76,6 +76,9 @@ class LanguageFacet extends React.PureComponent<Props> { | |||
getFacetItemText={this.getLanguageName} | |||
getSearchResultKey={(language: InstalledLanguage) => language.key} | |||
getSearchResultText={(language: InstalledLanguage) => language.name} | |||
// TODO use defaults when rules search WS is updated | |||
maxInitialItems={10} | |||
maxItems={10} | |||
onChange={this.props.onChange} | |||
onSearch={this.handleSearch} | |||
onToggle={this.props.onToggle} |
@@ -69,6 +69,9 @@ export default class TagFacet extends React.PureComponent<Props> { | |||
getFacetItemText={this.getTagName} | |||
getSearchResultKey={tag => tag} | |||
getSearchResultText={tag => tag} | |||
// TODO use defaults when rules search WS is updated | |||
maxInitialItems={10} | |||
maxItems={10} | |||
onChange={this.props.onChange} | |||
onSearch={this.handleSearch} | |||
onToggle={this.props.onToggle} |
@@ -55,7 +55,7 @@ export default class FacetItem extends React.PureComponent<Props> { | |||
}); | |||
return this.props.disabled ? ( | |||
<span className={className} data-facet={this.props.value}> | |||
<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>} | |||
</span> |
@@ -23,6 +23,7 @@ import FacetBox from './FacetBox'; | |||
import FacetHeader from './FacetHeader'; | |||
import FacetItem from './FacetItem'; | |||
import FacetItemsList from './FacetItemsList'; | |||
import ListStyleFacetFooter from './ListStyleFacetFooter'; | |||
import MultipleSelectionHint from './MultipleSelectionHint'; | |||
import { translate } from '../../helpers/l10n'; | |||
import DeferredSpinner from '../common/DeferredSpinner'; | |||
@@ -38,6 +39,8 @@ export interface Props<S> { | |||
getSearchResultKey: (result: S) => string; | |||
getSearchResultText: (result: S) => string; | |||
loading?: boolean; | |||
maxInitialItems?: number; | |||
maxItems?: number; | |||
onChange: (changes: { [x: string]: string | string[] }) => void; | |||
onSearch: (query: string, page?: number) => Promise<{ results: S[]; paging: Paging }>; | |||
onToggle: (property: string) => void; | |||
@@ -46,25 +49,32 @@ export interface Props<S> { | |||
renderFacetItem: (item: string) => React.ReactNode; | |||
renderSearchResult: (result: S, query: string) => React.ReactNode; | |||
searchPlaceholder: string; | |||
values: string[]; | |||
stats: { [x: string]: number } | undefined; | |||
values: string[]; | |||
} | |||
interface State<S> { | |||
autoFocus: boolean; | |||
query: string; | |||
searching: boolean; | |||
searchResults?: S[]; | |||
searchPaging?: Paging; | |||
searchResults?: S[]; | |||
showFullList: boolean; | |||
} | |||
export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> { | |||
mounted = false; | |||
static defaultProps = { | |||
maxInitialItems: 15, | |||
maxItems: 100 | |||
}; | |||
state: State<S> = { | |||
autoFocus: false, | |||
query: '', | |||
searching: false | |||
searching: false, | |||
showFullList: false | |||
}; | |||
componentDidMount() { | |||
@@ -72,9 +82,18 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S | |||
} | |||
componentDidUpdate(prevProps: Props<S>) { | |||
// focus search field *only* if it was manually open | |||
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 | |||
this.setState({ query: '', searchResults: undefined, searching: false, showFullList: false }); | |||
} else if ( | |||
prevProps.stats !== this.props.stats && | |||
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 }); | |||
} | |||
} | |||
@@ -139,11 +158,29 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S | |||
} | |||
}; | |||
getStat(item: string) { | |||
getStat(item: string, zeroIfAbsent = false) { | |||
const { stats } = this.props; | |||
return stats ? stats[item] : undefined; | |||
const defaultValue = zeroIfAbsent ? 0 : undefined; | |||
return stats && stats[item] !== undefined ? stats && stats[item] : defaultValue; | |||
} | |||
showFullList = () => { | |||
this.setState({ showFullList: true }); | |||
}; | |||
hideFullList = () => { | |||
this.setState({ showFullList: false }); | |||
}; | |||
getLastActiveIndex = (list: string[]) => { | |||
for (let i = list.length - 1; i >= 0; i--) { | |||
if (this.props.values.includes(list[i])) { | |||
return i; | |||
} | |||
} | |||
return 0; | |||
}; | |||
renderList() { | |||
const { stats } = this.props; | |||
@@ -151,27 +188,51 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S | |||
return null; | |||
} | |||
const items = sortBy( | |||
const sortedItems = 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 | |||
const lastSelectedIndex = this.getLastActiveIndex(sortedItems); | |||
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!; | |||
return ( | |||
<FacetItemsList> | |||
{items.map(item => ( | |||
<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))} | |||
tooltip={this.props.getFacetItemText(item)} | |||
value={item} | |||
/> | |||
))} | |||
</FacetItemsList> | |||
<> | |||
<FacetItemsList> | |||
{limitedList.map(item => ( | |||
<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))} | |||
tooltip={this.props.getFacetItemText(item)} | |||
value={item} | |||
/> | |||
))} | |||
</FacetItemsList> | |||
<ListStyleFacetFooter | |||
count={limitedList.length} | |||
showLess={this.state.showFullList ? this.hideFullList : undefined} | |||
showMore={this.showFullList} | |||
total={sortedItems.length} | |||
/> | |||
{mightHaveMoreResults && | |||
this.state.showFullList && ( | |||
<div className="alert alert-warning spacer-top"> | |||
{translate('facet_might_have_more_results')} | |||
</div> | |||
)} | |||
</> | |||
); | |||
} | |||
@@ -211,6 +272,7 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S | |||
{searchResults.map(result => this.renderSearchResult(result))} | |||
</FacetItemsList> | |||
<ListFooter | |||
className="spacer-bottom" | |||
count={searchResults.length} | |||
loadMore={this.searchMore} | |||
ready={!searching} | |||
@@ -223,7 +285,11 @@ 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); | |||
const stat = this.getStat(key); | |||
// 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 ( | |||
<FacetItem | |||
active={active} | |||
@@ -232,7 +298,7 @@ export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S | |||
loading={this.props.loading} | |||
name={this.props.renderSearchResult(result, this.state.query)} | |||
onClick={this.handleItemClick} | |||
stat={stat && formatFacetStat(stat)} | |||
stat={formatFacetStat(stat)} | |||
tooltip={this.props.getSearchResultText(result)} | |||
value={key} | |||
/> |
@@ -0,0 +1,71 @@ | |||
/* | |||
* 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 { translate, translateWithParameters } from '../../helpers/l10n'; | |||
import { formatMeasure } from '../../helpers/measures'; | |||
interface Props { | |||
className?: string; | |||
count: number; | |||
showMore: () => void; | |||
showLess: (() => void) | undefined; | |||
total: number; | |||
} | |||
export default class ListStyleFacetFooter extends React.PureComponent<Props> { | |||
handleShowMoreClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
event.currentTarget.blur(); | |||
this.props.showMore(); | |||
}; | |||
handleShowLessClick = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
event.currentTarget.blur(); | |||
if (this.props.showLess) { | |||
this.props.showLess(); | |||
} | |||
}; | |||
render() { | |||
const { count, total } = this.props; | |||
const hasMore = total > count; | |||
const allShown = Boolean(total && total === count); | |||
return ( | |||
<footer className="note spacer-top spacer-bottom text-center"> | |||
{translateWithParameters('x_show', formatMeasure(count, 'INT', null))} | |||
{hasMore && ( | |||
<a className="spacer-left text-muted" href="#" onClick={this.handleShowMoreClick}> | |||
{translate('show_more')} | |||
</a> | |||
)} | |||
{this.props.showLess && | |||
allShown && ( | |||
<a className="spacer-left text-muted" href="#" onClick={this.handleShowLessClick}> | |||
{translate('show_less')} | |||
</a> | |||
)} | |||
</footer> | |||
); | |||
} | |||
} |
@@ -116,6 +116,50 @@ it('should search', async () => { | |||
expect(onSearch).lastCalledWith('blabla'); | |||
}); | |||
it('should limit the number of items', () => { | |||
const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 5 }); | |||
expect(wrapper.find('FacetItem').length).toBe(2); | |||
wrapper.find('ListStyleFacetFooter').prop<Function>('showMore')(); | |||
wrapper.update(); | |||
expect(wrapper.find('FacetItem').length).toBe(3); | |||
wrapper.find('ListStyleFacetFooter').prop<Function>('showLess')(); | |||
wrapper.update(); | |||
expect(wrapper.find('FacetItem').length).toBe(2); | |||
}); | |||
it('should show warning that there might be more results', () => { | |||
const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 3 }); | |||
wrapper.find('ListStyleFacetFooter').prop<Function>('showMore')(); | |||
wrapper.update(); | |||
expect(wrapper.find('.alert-warning').exists()).toBe(true); | |||
}); | |||
it('should reset state when closes', () => { | |||
const wrapper = shallowRender(); | |||
wrapper.setState({ | |||
query: 'foobar', | |||
searchResults: ['foo', 'bar'], | |||
searching: true, | |||
showFullList: true | |||
}); | |||
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); | |||
}); | |||
it('should collapse list when new stats have few results', () => { | |||
const wrapper = shallowRender({ maxInitialItems: 2, maxItems: 3 }); | |||
wrapper.setState({ showFullList: true }); | |||
wrapper.setProps({ stats: { d: 1 } }); | |||
expect(wrapper.state('showFullList')).toBe(false); | |||
}); | |||
function shallowRender(props: Partial<Props<string>> = {}) { | |||
return shallow( | |||
<ListStyleFacet |
@@ -0,0 +1,56 @@ | |||
/* | |||
* 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 ListStyleFacetFooter from '../ListStyleFacetFooter'; | |||
import { click } from '../../../helpers/testUtils'; | |||
it('should not render "show more"', () => { | |||
expect( | |||
shallow(<ListStyleFacetFooter count={3} showLess={undefined} showMore={jest.fn()} total={3} />) | |||
).toMatchSnapshot(); | |||
}); | |||
it('should show more', () => { | |||
const showMore = jest.fn(); | |||
const wrapper = shallow( | |||
<ListStyleFacetFooter count={3} showLess={undefined} showMore={showMore} total={15} /> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
click(wrapper.find('a')); | |||
expect(showMore).toBeCalled(); | |||
}); | |||
it('should show less', () => { | |||
const showLess = jest.fn(); | |||
const wrapper = shallow( | |||
<ListStyleFacetFooter count={15} showLess={showLess} showMore={jest.fn()} total={15} /> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
click(wrapper.find('a')); | |||
expect(showLess).toBeCalled(); | |||
}); | |||
it('should not render "show less"', () => { | |||
const wrapper = shallow( | |||
<ListStyleFacetFooter count={15} showLess={undefined} showMore={jest.fn()} total={15} /> | |||
); | |||
expect(wrapper).toMatchSnapshot(); | |||
}); |
@@ -36,6 +36,7 @@ exports[`should render disabled 1`] = ` | |||
<span | |||
className="search-navigator-facet" | |||
data-facet="bar" | |||
title="foo" | |||
> | |||
<span | |||
className="facet-name" |
@@ -25,44 +25,51 @@ exports[`should render 1`] = ` | |||
placeholder="search for foo..." | |||
value="" | |||
/> | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="a" | |||
loading={false} | |||
name="a" | |||
onClick={[Function]} | |||
stat="10" | |||
tooltip="a" | |||
value="a" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="b" | |||
loading={false} | |||
name="b" | |||
onClick={[Function]} | |||
stat="8" | |||
tooltip="b" | |||
value="b" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="c" | |||
loading={false} | |||
name="c" | |||
onClick={[Function]} | |||
stat="1" | |||
tooltip="c" | |||
value="c" | |||
<React.Fragment> | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="a" | |||
loading={false} | |||
name="a" | |||
onClick={[Function]} | |||
stat="10" | |||
tooltip="a" | |||
value="a" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="b" | |||
loading={false} | |||
name="b" | |||
onClick={[Function]} | |||
stat="8" | |||
tooltip="b" | |||
value="b" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="c" | |||
loading={false} | |||
name="c" | |||
onClick={[Function]} | |||
stat="1" | |||
tooltip="c" | |||
value="c" | |||
/> | |||
</FacetItemsList> | |||
<ListStyleFacetFooter | |||
count={3} | |||
showMore={[Function]} | |||
total={3} | |||
/> | |||
</FacetItemsList> | |||
</React.Fragment> | |||
<MultipleSelectionHint | |||
options={3} | |||
values={0} | |||
@@ -100,28 +107,31 @@ exports[`should search 1`] = ` | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
disabled={true} | |||
halfWidth={false} | |||
key="d" | |||
loading={false} | |||
name="d" | |||
onClick={[Function]} | |||
stat={0} | |||
tooltip="d" | |||
value="d" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
disabled={true} | |||
halfWidth={false} | |||
key="e" | |||
loading={false} | |||
name="e" | |||
onClick={[Function]} | |||
stat={0} | |||
tooltip="e" | |||
value="e" | |||
/> | |||
</FacetItemsList> | |||
<ListFooter | |||
className="spacer-bottom" | |||
count={2} | |||
loadMore={[Function]} | |||
ready={true} | |||
@@ -165,39 +175,43 @@ exports[`should search 2`] = ` | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
disabled={true} | |||
halfWidth={false} | |||
key="d" | |||
loading={false} | |||
name="d" | |||
onClick={[Function]} | |||
stat={0} | |||
tooltip="d" | |||
value="d" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
disabled={true} | |||
halfWidth={false} | |||
key="e" | |||
loading={false} | |||
name="e" | |||
onClick={[Function]} | |||
stat={0} | |||
tooltip="e" | |||
value="e" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
disabled={true} | |||
halfWidth={false} | |||
key="f" | |||
loading={false} | |||
name="f" | |||
onClick={[Function]} | |||
stat={0} | |||
tooltip="f" | |||
value="f" | |||
/> | |||
</FacetItemsList> | |||
<ListFooter | |||
className="spacer-bottom" | |||
count={3} | |||
loadMore={[Function]} | |||
ready={true} | |||
@@ -237,44 +251,51 @@ exports[`should search 3`] = ` | |||
placeholder="search for foo..." | |||
value="" | |||
/> | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="a" | |||
loading={false} | |||
name="a" | |||
onClick={[Function]} | |||
stat="10" | |||
tooltip="a" | |||
value="a" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="b" | |||
loading={false} | |||
name="b" | |||
onClick={[Function]} | |||
stat="8" | |||
tooltip="b" | |||
value="b" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="c" | |||
loading={false} | |||
name="c" | |||
onClick={[Function]} | |||
stat="1" | |||
tooltip="c" | |||
value="c" | |||
<React.Fragment> | |||
<FacetItemsList> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="a" | |||
loading={false} | |||
name="a" | |||
onClick={[Function]} | |||
stat="10" | |||
tooltip="a" | |||
value="a" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="b" | |||
loading={false} | |||
name="b" | |||
onClick={[Function]} | |||
stat="8" | |||
tooltip="b" | |||
value="b" | |||
/> | |||
<FacetItem | |||
active={false} | |||
disabled={false} | |||
halfWidth={false} | |||
key="c" | |||
loading={false} | |||
name="c" | |||
onClick={[Function]} | |||
stat="1" | |||
tooltip="c" | |||
value="c" | |||
/> | |||
</FacetItemsList> | |||
<ListStyleFacetFooter | |||
count={3} | |||
showMore={[Function]} | |||
total={3} | |||
/> | |||
</FacetItemsList> | |||
</React.Fragment> | |||
<MultipleSelectionHint | |||
options={3} | |||
values={0} |
@@ -0,0 +1,47 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should not render "show less" 1`] = ` | |||
<footer | |||
className="note spacer-top spacer-bottom text-center" | |||
> | |||
x_show.15 | |||
</footer> | |||
`; | |||
exports[`should not render "show more" 1`] = ` | |||
<footer | |||
className="note spacer-top spacer-bottom text-center" | |||
> | |||
x_show.3 | |||
</footer> | |||
`; | |||
exports[`should show less 1`] = ` | |||
<footer | |||
className="note spacer-top spacer-bottom text-center" | |||
> | |||
x_show.15 | |||
<a | |||
className="spacer-left text-muted" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
show_less | |||
</a> | |||
</footer> | |||
`; | |||
exports[`should show more 1`] = ` | |||
<footer | |||
className="note spacer-top spacer-bottom text-center" | |||
> | |||
x_show.3 | |||
<a | |||
className="spacer-left text-muted" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
show_more | |||
</a> | |||
</footer> | |||
`; |
@@ -218,6 +218,7 @@ created_by=Created by | |||
default_error_message=The request cannot be processed. Try again later. | |||
default_severity=Default severity | |||
edit_permissions=Edit Permissions | |||
facet_might_have_more_results=There might be more results, try another set of filters to see them. | |||
false_positive=False positive | |||
go_back_to_homepage=Go back to the homepage | |||
last_analysis_before=Last analysis before | |||
@@ -241,6 +242,7 @@ set_as_default=Set as Default | |||
short_number_suffix.g=G | |||
short_number_suffix.k=k | |||
short_number_suffix.m=M | |||
show_less=Show Less | |||
show_more=Show More | |||
show_all=Show All | |||
should_be_unique=Should be unique |