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}
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}
});
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>
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';
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;
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() {
}
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 });
}
}
}
};
- 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;
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>
+ )}
+ </>
);
}
{searchResults.map(result => this.renderSearchResult(result))}
</FacetItemsList>
<ListFooter
+ className="spacer-bottom"
count={searchResults.length}
loadMore={this.searchMore}
ready={!searching}
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}
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}
/>
--- /dev/null
+/*
+ * 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>
+ );
+ }
+}
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
--- /dev/null
+/*
+ * 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();
+});
<span
className="search-navigator-facet"
data-facet="bar"
+ title="foo"
>
<span
className="facet-name"
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}
<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}
<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}
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}
--- /dev/null
+// 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>
+`;
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
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