Browse Source

SONAR-6961 Add issue counts to search in rule facet on issue page (#612)

tags/7.5
Stas Vilchik 5 years ago
parent
commit
63055cd49b
31 changed files with 815 additions and 1037 deletions
  1. 25
    1
      server/sonar-web/src/main/js/apps/issues/components/App.tsx
  2. 72
    257
      server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
  3. 10
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx
  4. 1
    7
      server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx
  5. 10
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx
  6. 10
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx
  7. 12
    5
      server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
  8. 10
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/ModuleFacet.tsx
  9. 12
    4
      server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
  10. 0
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx
  11. 14
    13
      server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
  12. 0
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.tsx
  13. 22
    17
      server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
  14. 36
    71
      server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
  15. 0
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.tsx
  16. 10
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
  17. 0
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx
  18. 26
    45
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
  19. 1
    0
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-test.tsx
  20. 3
    14
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx
  21. 33
    315
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
  22. 28
    157
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap
  23. 84
    0
      server/sonar-web/src/main/js/components/controls/MouseOverHandler.tsx
  24. 88
    0
      server/sonar-web/src/main/js/components/controls/__tests__/MouseOverHandler-test.tsx
  25. 19
    2
      server/sonar-web/src/main/js/components/facet/FacetItem.tsx
  26. 39
    0
      server/sonar-web/src/main/js/components/facet/ListStyleFacet.css
  27. 130
    31
      server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx
  28. 0
    4
      server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx
  29. 45
    5
      server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx
  30. 0
    16
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap
  31. 75
    55
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap

+ 25
- 1
server/sonar-web/src/main/js/apps/issues/components/App.tsx View File

@@ -658,6 +658,30 @@ export default class App extends React.PureComponent<Props, State> {
});
};

loadSearchResultCount = (changes: Partial<Query>) => {
const { component } = this.props;
const { myIssues, query } = this.state;

const organizationKey =
(component && component.organization) ||
(this.props.organization && this.props.organization.key);

const parameters = {
...getBranchLikeQuery(this.props.branchLike),
componentKeys: component && component.key,
s: 'FILE_LINE',
...serializeQuery({ ...query, ...changes }),
ps: 1,
organization: organizationKey
};

if (myIssues) {
Object.assign(parameters, { assignees: '__me__' });
}

return this.props.fetchIssues(parameters, false).then(reponse => reponse.paging.total);
};

closeFacet = (property: string) => {
this.setState(state => ({
openFacets: { ...state.openFacets, [property]: false }
@@ -907,7 +931,7 @@ export default class App extends React.PureComponent<Props, State> {
component={component}
facets={this.state.facets}
hideAuthorFacet={hideAuthorFacet}
loading={this.state.loading}
loadSearchResultCount={this.loadSearchResultCount}
loadingFacets={this.state.loadingFacets}
myIssues={this.state.myIssues}
onFacetToggle={this.handleFacetToggle}

+ 72
- 257
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx View File

@@ -18,101 +18,32 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import {
searchAssignees,
formatFacetStat,
Query,
ReferencedUser,
SearchedAssignee
} from '../utils';
import { Component, Paging } from '../../../app/types';
import FacetBox from '../../../components/facet/FacetBox';
import FacetHeader from '../../../components/facet/FacetHeader';
import FacetItem from '../../../components/facet/FacetItem';
import FacetItemsList from '../../../components/facet/FacetItemsList';
import { omit, sortBy, without } from 'lodash';
import { searchAssignees, Query, ReferencedUser, SearchedAssignee } from '../utils';
import { Component } from '../../../app/types';
import Avatar from '../../../components/ui/Avatar';
import { translate } from '../../../helpers/l10n';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
import SearchBox from '../../../components/controls/SearchBox';
import ListFooter from '../../../components/controls/ListFooter';
import { highlightTerm } from '../../../helpers/search';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';

export interface Props {
assigned: boolean;
assignees: string[];
component: Component | undefined;
fetching: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
organization: string | undefined;
query: Query;
stats: { [x: string]: number } | undefined;
referencedUsers: { [login: string]: ReferencedUser };
}

interface State {
query: string;
searching: boolean;
searchResults?: SearchedAssignee[];
searchPaging?: Paging;
}

export default class AssigneeFacet extends React.PureComponent<Props, State> {
mounted = false;
property = 'assignees';

state: State = {
query: '',
searching: false
};

componentDidMount() {
this.mounted = true;
}

componentWillUnmount() {
this.mounted = false;
}

stopSearching = () => {
if (this.mounted) {
this.setState({ searching: false });
}
};

search = (query: string) => {
if (query.length >= 2) {
this.setState({ query, searching: true });
searchAssignees(query, this.props.organization).then(({ paging, results }) => {
if (this.mounted) {
this.setState({ searching: false, searchResults: results, searchPaging: paging });
}
}, this.stopSearching);
} else {
this.setState({ query, searching: false, searchResults: [] });
}
};

searchMore = () => {
const { query, searchPaging, searchResults } = this.state;
if (query && searchResults && searchPaging) {
this.setState({ searching: true });
searchAssignees(query, this.props.organization, searchPaging.pageIndex + 1).then(
({ paging, results }) => {
if (this.mounted) {
this.setState({
searching: false,
searchResults: [...searchResults, ...results],
searchPaging: paging
});
}
},
this.stopSearching
);
}
export default class AssigneeFacet extends React.PureComponent<Props> {
handleSearch = (query: string, page?: number) => {
return searchAssignees(query, this.props.organization, page);
};

handleItemClick = (itemValue: string, multiple: boolean) => {
@@ -124,221 +55,105 @@ export default class AssigneeFacet extends React.PureComponent<Props, State> {
const newValue = sortBy(
assignees.includes(itemValue) ? without(assignees, itemValue) : [...assignees, itemValue]
);
this.props.onChange({ assigned: true, [this.property]: newValue });
this.props.onChange({ assigned: true, assignees: newValue });
} else {
this.props.onChange({
assigned: true,
[this.property]: assignees.includes(itemValue) && assignees.length < 2 ? [] : [itemValue]
assignees: assignees.includes(itemValue) && assignees.length < 2 ? [] : [itemValue]
});
}
};

handleHeaderClick = () => {
this.props.onToggle(this.property);
};

handleClear = () => {
this.props.onChange({ assigned: true, assignees: [] });
};

handleSelect = (option: { value: string }) => {
const { assignees } = this.props;
this.props.onChange({ assigned: true, [this.property]: uniq([...assignees, option.value]) });
};

isAssigneeActive(assignee: string) {
return assignee === '' ? !this.props.assigned : this.props.assignees.includes(assignee);
}

getAssigneeNameAndTooltip(assignee: string) {
getAssigneeName = (assignee: string) => {
if (assignee === '') {
return { name: translate('unassigned'), tooltip: translate('unassigned') };
return translate('unassigned');
} else {
const { referencedUsers } = this.props;
if (referencedUsers[assignee]) {
return {
name: (
<span>
<Avatar
className="little-spacer-right"
hash={referencedUsers[assignee].avatar}
name={referencedUsers[assignee].name}
size={16}
/>
{referencedUsers[assignee].name}
</span>
),
tooltip: referencedUsers[assignee].name
};
} else {
return { name: assignee, tooltip: assignee };
}
}
}

getStat(assignee: string) {
const { stats } = this.props;
return stats ? stats[assignee] : undefined;
}

getValues() {
const values = this.props.assignees.map(assignee => {
const user = this.props.referencedUsers[assignee];
return user ? user.name : assignee;
});
if (!this.props.assigned) {
values.push(translate('unassigned'));
}
return values;
}

renderOption = (option: { avatar: string; label: string }) => {
return this.renderAssignee(option.avatar, option.label);
};

renderAssignee = (avatar: string | undefined, name: string) => (
<span>
{avatar !== undefined && (
<Avatar className="little-spacer-right" hash={avatar} name={name} size={16} />
)}
{name}
</span>
);

renderListItem(assignee: string) {
const { name, tooltip } = this.getAssigneeNameAndTooltip(assignee);
return (
<FacetItem
active={this.isAssigneeActive(assignee)}
key={assignee}
loading={this.props.loading}
name={name}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(assignee))}
tooltip={tooltip}
value={assignee}
/>
);
}

renderList() {
const { stats } = this.props;

if (!stats) {
return null;
}
loadSearchResultCount = (assignee: SearchedAssignee) => {
return this.props.loadSearchResultCount({ assigned: undefined, assignees: [assignee.login] });
};

const assignees = sortBy(
getSortedItems = () => {
const { stats = {} } = this.props;
return sortBy(
Object.keys(stats),
// put unassigned first
// put "not assigned" first
key => (key === '' ? 0 : 1),
// the sort by number
key => -stats[key]
);
};

return (
<FacetItemsList>{assignees.map(assignee => this.renderListItem(assignee))}</FacetItemsList>
);
}

renderSearch() {
if (!this.props.stats || !Object.keys(this.props.stats).length) {
return null;
}

return (
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
loading={this.state.searching}
minLength={2}
onChange={this.search}
placeholder={translate('search.search_for_users')}
value={this.state.query}
/>
);
}

renderSearchResults() {
const { searching, searchResults, searchPaging } = this.state;

if (!searching && (!searchResults || !searchResults.length)) {
return <div className="note spacer-bottom">{translate('no_results')}</div>;
}

if (!searchResults || !searchPaging) {
// initial search
return null;
renderFacetItem = (assignee: string) => {
if (assignee === '') {
return translate('unassigned');
}

return (
const user = this.props.referencedUsers[assignee];
return user ? (
<>
<FacetItemsList>
{searchResults.map(result => this.renderSearchResult(result))}
</FacetItemsList>
<ListFooter
count={searchResults.length}
loadMore={this.searchMore}
ready={!searching}
total={searchPaging.total}
/>
<Avatar className="little-spacer-right" hash={user.avatar} name={user.name} size={16} />
{user.name}
</>
) : (
assignee
);
}
};

renderSearchResult(result: SearchedAssignee) {
const active = this.props.assignees.includes(result.login);
const stat = this.getStat(result.login);
renderSearchResult = (result: SearchedAssignee, query: string) => {
return (
<FacetItem
active={active}
disabled={!active && stat === 0}
key={result.login}
loading={this.props.loading}
name={
<>
{result.avatar !== undefined && (
<Avatar
className="little-spacer-right"
hash={result.avatar}
name={result.name}
size={16}
/>
)}
{highlightTerm(result.name, this.state.query)}
</>
}
onClick={this.handleItemClick}
stat={stat && formatFacetStat(stat)}
tooltip={result.name}
value={result.login}
/>
<>
{result.avatar !== undefined && (
<Avatar
className="little-spacer-right"
hash={result.avatar}
name={result.name}
size={16}
/>
)}
{highlightTerm(result.name, query)}
</>
);
}
};

render() {
const { assignees, stats = {} } = this.props;
return (
<FacetBox property={this.property}>
<FacetHeader
name={translate('issues.facet', this.property)}
onClear={this.handleClear}
onClick={this.handleHeaderClick}
open={this.props.open}
values={this.getValues()}
/>
const values = [...this.props.assignees];
if (!this.props.assigned) {
values.push('');
}

<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderSearch()}
{this.state.query && this.state.searchResults !== undefined
? this.renderSearchResults()
: this.renderList()}
<MultipleSelectionHint options={Object.keys(stats).length} values={assignees.length} />
</>
)}
</FacetBox>
return (
<ListStyleFacet<SearchedAssignee>
facetHeader={translate('issues.facet.assignees')}
fetching={this.props.fetching}
getFacetItemText={this.getAssigneeName}
getSearchResultKey={user => user.login}
getSearchResultText={user => user.name}
// put "not assigned" item first
getSortedItems={this.getSortedItems}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onClear={this.handleClear}
onItemClick={this.handleItemClick}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="assignees"
query={omit(this.props.query, 'assigned', 'assignees')}
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_users')}
stats={this.props.stats}
values={values}
/>
);
}
}

+ 10
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import { Query } from '../utils';
import { translate } from '../../../helpers/l10n';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
@@ -27,11 +28,12 @@ import { highlightTerm } from '../../../helpers/search';
interface Props {
componentKey: string | undefined;
fetching: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
organization: string | undefined;
query: Query;
stats: { [x: string]: number } | undefined;
authors: string[];
}
@@ -52,23 +54,29 @@ export default class AuthorFacet extends React.PureComponent<Props> {
}).then(authors => ({ maxResults: authors.length === SEARCH_SIZE, results: authors }));
};

loadSearchResultCount = (author: string) => {
return this.props.loadSearchResultCount({ authors: [author] });
};

renderSearchResult = (author: string, term: string) => {
return highlightTerm(author, term);
};

render() {
return (
<ListStyleFacet
<ListStyleFacet<string>
facetHeader={translate('issues.facet.authors')}
fetching={this.props.fetching}
getFacetItemText={this.identity}
getSearchResultKey={this.identity}
getSearchResultText={this.identity}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="authors"
query={omit(this.props.query, 'authors')}
renderFacetItem={this.identity}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_authors')}

+ 1
- 7
server/sonar-web/src/main/js/apps/issues/sidebar/CreationDateFacet.tsx View File

@@ -42,7 +42,6 @@ interface Props {
createdBefore: Date | undefined;
createdInLast: string;
fetching: boolean;
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
@@ -173,7 +172,7 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
createdBefore: endDate,
tooltip,
x: index,
y: this.props.loading ? 0 : stats[start]
y: stats[start]
};
});

@@ -226,7 +225,6 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
<div className="spacer-top issues-predefined-periods">
<FacetItem
active={!this.hasValue()}
loading={this.props.loading}
name={translate('issues.facet.createdAt.all')}
onClick={this.handlePeriodClick}
tooltip={translate('issues.facet.createdAt.all')}
@@ -235,7 +233,6 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
{component ? (
<FacetItem
active={sinceLeakPeriod}
loading={this.props.loading}
name={translate('issues.new_code')}
onClick={this.handleLeakPeriodClick}
tooltip={translate('issues.leak_period')}
@@ -245,7 +242,6 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
<>
<FacetItem
active={createdInLast === '1w'}
loading={this.props.loading}
name={translate('issues.facet.createdAt.last_week')}
onClick={this.handlePeriodClick}
tooltip={translate('issues.facet.createdAt.last_week')}
@@ -253,7 +249,6 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
/>
<FacetItem
active={createdInLast === '1m'}
loading={this.props.loading}
name={translate('issues.facet.createdAt.last_month')}
onClick={this.handlePeriodClick}
tooltip={translate('issues.facet.createdAt.last_month')}
@@ -261,7 +256,6 @@ export default class CreationDateFacet extends React.PureComponent<Props> {
/>
<FacetItem
active={createdInLast === '1y'}
loading={this.props.loading}
name={translate('issues.facet.createdAt.last_year')}
onClick={this.handlePeriodClick}
tooltip={translate('issues.facet.createdAt.last_year')}

+ 10
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import { Query } from '../utils';
import QualifierIcon from '../../../components/icons-components/QualifierIcon';
import { translate } from '../../../helpers/l10n';
@@ -30,10 +31,11 @@ interface Props {
componentKey: string;
fetching: boolean;
directories: string[];
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
query: Query;
stats: { [x: string]: number } | undefined;
}

@@ -60,6 +62,10 @@ export default class DirectoryFacet extends React.PureComponent<Props> {
}).then(({ components, paging }) => ({ paging, results: components }));
};

loadSearchResultCount = (directory: TreeComponent) => {
return this.props.loadSearchResultCount({ directories: [directory.name] });
};

renderDirectory = (directory: React.ReactNode) => (
<>
<QualifierIcon className="little-spacer-right" qualifier="DIR" />
@@ -77,18 +83,20 @@ export default class DirectoryFacet extends React.PureComponent<Props> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<TreeComponent>
facetHeader={translate('issues.facet.directories')}
fetching={this.props.fetching}
getFacetItemText={this.getFacetItemText}
getSearchResultKey={this.getSearchResultKey}
getSearchResultText={this.getSearchResultText}
loadSearchResultCount={this.loadSearchResultCount}
minSearchLength={3}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="directories"
query={omit(this.props.query, 'directories')}
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_directories')}

+ 10
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import { Query, ReferencedComponent } from '../utils';
import QualifierIcon from '../../../components/icons-components/QualifierIcon';
import { translate } from '../../../helpers/l10n';
@@ -30,10 +31,11 @@ interface Props {
componentKey: string;
fetching: boolean;
files: string[];
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
query: Query;
referencedComponents: { [componentKey: string]: ReferencedComponent };
stats: { [x: string]: number } | undefined;
}
@@ -67,6 +69,10 @@ export default class FileFacet extends React.PureComponent<Props> {
}).then(({ components, paging }) => ({ paging, results: components }));
};

loadSearchResultCount = (file: TreeComponent) => {
return this.props.loadSearchResultCount({ files: [file.id] });
};

renderFile = (file: React.ReactNode) => (
<>
<QualifierIcon className="little-spacer-right" qualifier="FIL" />
@@ -85,18 +91,20 @@ export default class FileFacet extends React.PureComponent<Props> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<TreeComponent>
facetHeader={translate('issues.facet.files')}
fetching={this.props.fetching}
getFacetItemText={this.getFacetItemText}
getSearchResultKey={this.getSearchResultKey}
getSearchResultText={this.getSearchResultText}
loadSearchResultCount={this.loadSearchResultCount}
minSearchLength={3}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="files"
query={omit(this.props.query, 'files')}
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_files')}

+ 12
- 5
server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx View File

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { uniqBy } from 'lodash';
import { uniqBy, omit } from 'lodash';
import { connect } from 'react-redux';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedLanguage } from '../utils';
@@ -35,10 +35,11 @@ interface Props {
fetching: boolean;
installedLanguages: InstalledLanguage[];
languages: string[];
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
query: Query;
referencedLanguages: { [languageKey: string]: ReferencedLanguage };
stats: { [x: string]: number } | undefined;
}
@@ -70,23 +71,29 @@ class LanguageFacet extends React.PureComponent<Props> {
);
};

loadSearchResultCount = (language: InstalledLanguage) => {
return this.props.loadSearchResultCount({ languages: [language.key] });
};

renderSearchResult = ({ name }: InstalledLanguage, term: string) => {
return highlightTerm(name, term);
};

render() {
return (
<ListStyleFacet
<ListStyleFacet<InstalledLanguage>
facetHeader={translate('issues.facet.languages')}
fetching={this.props.fetching}
getFacetItemText={this.getLanguageName}
getSearchResultKey={(language: InstalledLanguage) => language.key}
getSearchResultText={(language: InstalledLanguage) => language.name}
getSearchResultKey={language => language.key}
getSearchResultText={language => language.name}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="languages"
query={omit(this.props.query, 'languages')}
renderFacetItem={this.getLanguageName}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_languages')}

+ 10
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/ModuleFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import { Query, ReferencedComponent } from '../utils';
import QualifierIcon from '../../../components/icons-components/QualifierIcon';
import { translate } from '../../../helpers/l10n';
@@ -28,11 +29,12 @@ import { highlightTerm } from '../../../helpers/search';
interface Props {
componentKey: string;
fetching: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
modules: string[];
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
query: Query;
referencedComponents: { [componentKey: string]: ReferencedComponent };
stats: { [x: string]: number } | undefined;
}
@@ -61,6 +63,10 @@ export default class ModuleFacet extends React.PureComponent<Props> {
}).then(({ components, paging }) => ({ paging, results: components }));
};

loadSearchResultCount = (module: TreeComponent) => {
return this.props.loadSearchResultCount({ files: [module.id] });
};

renderModule = (module: React.ReactNode) => (
<>
<QualifierIcon className="little-spacer-right" qualifier="BRC" />
@@ -79,18 +85,20 @@ export default class ModuleFacet extends React.PureComponent<Props> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<TreeComponent>
facetHeader={translate('issues.facet.modules')}
fetching={this.props.fetching}
getFacetItemText={this.getModuleName}
getSearchResultKey={this.getSearchResultKey}
getSearchResultText={this.getSearchResultText}
loadSearchResultCount={this.loadSearchResultCount}
minSearchLength={3}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="modules"
query={omit(this.props.query, 'modules')}
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_modules')}

+ 12
- 4
server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedComponent } from '../utils';
import { searchProjects, getTree } from '../../../api/components';
@@ -29,13 +30,14 @@ import { highlightTerm } from '../../../helpers/search';

interface Props {
component: Component | undefined;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
fetching: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
organization: { key: string } | undefined;
projects: string[];
query: Query;
referencedComponents: { [componentKey: string]: ReferencedComponent };
stats: { [x: string]: number } | undefined;
}
@@ -91,6 +93,10 @@ export default class ProjectFacet extends React.PureComponent<Props> {
return referencedComponents[project] ? referencedComponents[project].name : project;
};

loadSearchResultCount = (project: SearchedProject) => {
return this.props.loadSearchResultCount({ projects: [project.id] });
};

renderFacetItem = (project: string) => {
const { referencedComponents } = this.props;
return referencedComponents[project] ? (
@@ -125,17 +131,19 @@ export default class ProjectFacet extends React.PureComponent<Props> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<SearchedProject>
facetHeader={translate('issues.facet.projects')}
fetching={this.props.fetching}
getFacetItemText={this.getProjectName}
getSearchResultKey={(project: SearchedProject) => project.id}
getSearchResultText={(project: SearchedProject) => project.name}
getSearchResultKey={project => project.id}
getSearchResultText={project => project.name}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="projects"
query={omit(this.props.query, 'projects')}
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_projects')}

+ 0
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx View File

@@ -30,7 +30,6 @@ import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHi

interface Props {
fetching: boolean;
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
@@ -100,7 +99,6 @@ export default class ResolutionFacet extends React.PureComponent<Props> {
disabled={stat === 0 && !active}
halfWidth={true}
key={resolution}
loading={this.props.loading}
name={this.getFacetItemName(resolution)}
onClick={this.handleItemClick}
stat={formatFacetStat(stat)}

+ 14
- 13
server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx View File

@@ -18,33 +18,28 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedRule } from '../utils';
import { searchRules } from '../../../api/rules';
import { Rule, Paging } from '../../../app/types';
import { Rule } from '../../../app/types';
import { translate } from '../../../helpers/l10n';

interface Props {
fetching: boolean;
languages: string[];
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
organization: string | undefined;
query: Query;
referencedRules: { [ruleKey: string]: ReferencedRule };
rules: string[];
stats: { [x: string]: number } | undefined;
}

interface State {
query: string;
searching: boolean;
searchResults?: Rule[];
searchPaging?: Paging;
}

export default class RuleFacet extends React.PureComponent<Props, State> {
export default class RuleFacet extends React.PureComponent<Props> {
handleSearch = (query: string, page = 1) => {
const { languages, organization } = this.props;
return searchRules({
@@ -63,6 +58,10 @@ export default class RuleFacet extends React.PureComponent<Props, State> {
}));
};

loadSearchResultCount = (rule: Rule) => {
return this.props.loadSearchResultCount({ rules: [rule.key] });
};

getRuleName = (rule: string) => {
const { referencedRules } = this.props;
return referencedRules[rule]
@@ -76,17 +75,19 @@ export default class RuleFacet extends React.PureComponent<Props, State> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<Rule>
facetHeader={translate('issues.facet.rules')}
fetching={this.props.fetching}
getFacetItemText={this.getRuleName}
getSearchResultKey={result => result.key}
getSearchResultText={result => result.name}
getSearchResultKey={rule => rule.key}
getSearchResultText={rule => rule.name}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="rules"
query={omit(this.props.query, 'rules')}
renderFacetItem={this.getRuleName}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_rules')}

+ 0
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/SeverityFacet.tsx View File

@@ -31,7 +31,6 @@ import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHi

interface Props {
fetching: boolean;
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
@@ -85,7 +84,6 @@ export default class SeverityFacet extends React.PureComponent<Props> {
disabled={stat === 0 && !active}
halfWidth={true}
key={severity}
loading={this.props.loading}
name={<SeverityHelper severity={severity} />}
onClick={this.handleItemClick}
stat={formatFacetStat(stat)}

+ 22
- 17
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx View File

@@ -48,7 +48,7 @@ export interface Props {
component: Component | undefined;
facets: { [facet: string]: Facet };
hideAuthorFacet?: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
loadingFacets: { [key: string]: boolean };
myIssues: boolean;
onFacetToggle: (property: string) => void;
@@ -81,7 +81,6 @@ export default class Sidebar extends React.PureComponent<Props> {
<>
<TypeFacet
fetching={this.props.loadingFacets.types === true}
loading={this.props.loading}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.types}
@@ -90,7 +89,6 @@ export default class Sidebar extends React.PureComponent<Props> {
/>
<SeverityFacet
fetching={this.props.loadingFacets.severities === true}
loading={this.props.loading}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.severities}
@@ -99,7 +97,6 @@ export default class Sidebar extends React.PureComponent<Props> {
/>
<ResolutionFacet
fetching={this.props.loadingFacets.resolutions === true}
loading={this.props.loading}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.resolutions}
@@ -109,7 +106,6 @@ export default class Sidebar extends React.PureComponent<Props> {
/>
<StatusFacet
fetching={this.props.loadingFacets.statuses === true}
loading={this.props.loading}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.statuses}
@@ -123,7 +119,6 @@ export default class Sidebar extends React.PureComponent<Props> {
createdBefore={query.createdBefore}
createdInLast={query.createdInLast}
fetching={this.props.loadingFacets.createdAt === true}
loading={this.props.loading}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.createdAt}
@@ -133,21 +128,23 @@ export default class Sidebar extends React.PureComponent<Props> {
<LanguageFacet
fetching={this.props.loadingFacets.languages === true}
languages={query.languages}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.languages}
query={query}
referencedLanguages={this.props.referencedLanguages}
stats={facets.languages}
/>
<RuleFacet
fetching={this.props.loadingFacets.rules === true}
languages={query.languages}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.rules}
organization={organizationKey}
query={query}
referencedRules={this.props.referencedRules}
rules={query.rules}
stats={facets.rules}
@@ -159,13 +156,14 @@ export default class Sidebar extends React.PureComponent<Props> {
fetchingCwe={this.props.loadingFacets.cwe === true}
fetchingOwaspTop10={this.props.loadingFacets.owaspTop10 === true}
fetchingSansTop25={this.props.loadingFacets.sansTop25 === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets[STANDARDS]}
owaspTop10={query.owaspTop10}
owaspTop10Open={!!openFacets.owaspTop10}
owaspTop10Stats={facets.owaspTop10}
query={query}
sansTop25={query.sansTop25}
sansTop25Open={!!openFacets.sansTop25}
sansTop25Stats={facets.sansTop25}
@@ -173,11 +171,12 @@ export default class Sidebar extends React.PureComponent<Props> {
<TagFacet
component={component}
fetching={this.props.loadingFacets.tags === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.tags}
organization={organizationKey}
query={query}
stats={facets.tags}
tags={query.tags}
/>
@@ -185,12 +184,13 @@ export default class Sidebar extends React.PureComponent<Props> {
<ProjectFacet
component={component}
fetching={this.props.loadingFacets.projects === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.projects}
organization={this.props.organization}
projects={query.projects}
query={query}
referencedComponents={this.props.referencedComponents}
stats={facets.projects}
/>
@@ -199,11 +199,12 @@ export default class Sidebar extends React.PureComponent<Props> {
<ModuleFacet
componentKey={this.props.component!.key}
fetching={this.props.loadingFacets.modules === true}
loading={this.props.loading}
modules={query.modules}
loadSearchResultCount={this.props.loadSearchResultCount}
modules={query.files}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.modules}
query={query}
referencedComponents={this.props.referencedComponents}
stats={facets.modules}
/>
@@ -213,10 +214,11 @@ export default class Sidebar extends React.PureComponent<Props> {
componentKey={this.props.component!.key}
directories={query.directories}
fetching={this.props.loadingFacets.directories === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.directories}
query={query}
stats={facets.directories}
/>
)}
@@ -225,10 +227,11 @@ export default class Sidebar extends React.PureComponent<Props> {
componentKey={this.props.component!.key}
fetching={this.props.loadingFacets.files === true}
files={query.files}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.files}
query={query}
referencedComponents={this.props.referencedComponents}
stats={facets.files}
/>
@@ -239,11 +242,12 @@ export default class Sidebar extends React.PureComponent<Props> {
assignees={query.assignees}
component={component}
fetching={this.props.loadingFacets.assignees === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.assignees}
organization={organizationKey}
query={query}
referencedUsers={this.props.referencedUsers}
stats={facets.assignees}
/>
@@ -253,11 +257,12 @@ export default class Sidebar extends React.PureComponent<Props> {
authors={query.authors}
componentKey={this.props.component && this.props.component.key}
fetching={this.props.loadingFacets.authors === true}
loading={this.props.loading}
loadSearchResultCount={this.props.loadSearchResultCount}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
open={!!openFacets.authors}
organization={organizationKey}
query={query}
stats={facets.authors}
/>
)}

+ 36
- 71
server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx View File

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, without } from 'lodash';
import { sortBy, without, omit } from 'lodash';
import { Query, STANDARDS, formatFacetStat } from '../utils';
import FacetBox from '../../../components/facet/FacetBox';
import FacetHeader from '../../../components/facet/FacetHeader';
@@ -33,8 +33,8 @@ import {
} from '../../securityReports/utils';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
import SearchBox from '../../../components/controls/SearchBox';
import { highlightTerm } from '../../../helpers/search';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';

export interface Props {
cwe: string[];
@@ -43,13 +43,14 @@ export interface Props {
fetchingOwaspTop10: boolean;
fetchingSansTop25: boolean;
fetchingCwe: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
owaspTop10: string[];
owaspTop10Open: boolean;
owaspTop10Stats: { [x: string]: number } | undefined;
query: Query;
sansTop25: string[];
sansTop25Open: boolean;
sansTop25Stats: { [x: string]: number } | undefined;
@@ -132,10 +133,6 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
this.props.onToggle('sansTop25');
};

handleCWEHeaderClick = () => {
this.props.onToggle('cwe');
};

handleClear = () => {
this.props.onChange({ [this.property]: [], owaspTop10: [], sansTop25: [], cwe: [] });
};
@@ -158,20 +155,22 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
this.handleItemClick('owaspTop10', itemValue, multiple);
};

handleCWEItemClick = (itemValue: string, multiple: boolean) => {
this.handleItemClick('cwe', itemValue, multiple);
};

handleSansTop25ItemClick = (itemValue: string, multiple: boolean) => {
this.handleItemClick('sansTop25', itemValue, multiple);
};

handleCWESelect = ({ value }: { value: string }) => {
this.handleItemClick('cwe', value, true);
handleCWESearch = (query: string) => {
return Promise.resolve({
results: Object.keys(this.state.standards.cwe).filter(cwe =>
renderCWECategory(this.state.standards, cwe)
.toLowerCase()
.includes(query.toLowerCase())
)
});
};

handleCWESearch = (query: string) => {
this.setState({ cweQuery: query });
loadCWESearchResultCount = (category: string) => {
return this.props.loadSearchResultCount({ cwe: [category] });
};

renderList = (
@@ -216,7 +215,6 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
<FacetItem
active={values.includes(category)}
key={category}
loading={this.props.loading}
name={renderName(this.state.standards, category)}
onClick={onClick}
stat={formatFacetStat(getStat(category))}
@@ -247,45 +245,6 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
return this.renderHint('owaspTop10Stats', 'owaspTop10');
}

renderCWEList() {
const { cweQuery } = this.state;
if (cweQuery) {
const results = Object.keys(this.state.standards.cwe).filter(cwe =>
renderCWECategory(this.state.standards, cwe)
.toLowerCase()
.includes(cweQuery.toLowerCase())
);

return this.renderFacetItemsList(
this.props.cweStats,
this.props.cwe,
results,
(standards: Standards, category: string) =>
highlightTerm(renderCWECategory(standards, category), cweQuery),
renderCWECategory,
this.handleCWEItemClick
);
} else {
return this.renderList('cweStats', 'cwe', renderCWECategory, this.handleCWEItemClick);
}
}

renderCWESearch() {
return (
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
onChange={this.handleCWESearch}
placeholder={translate('search.search_for_cwe')}
value={this.state.cweQuery}
/>
);
}

renderCWEHint() {
return this.renderHint('cweStats', 'cwe');
}

renderSansTop25List() {
return this.renderList(
'sansTop25Stats',
@@ -336,22 +295,28 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
</>
)}
</FacetBox>
<FacetBox className="is-inner" property="cwe">
<FacetHeader
name={translate('issues.facet.cwe')}
onClick={this.handleCWEHeaderClick}
open={this.props.cweOpen}
values={this.props.cwe.map(item => renderCWECategory(this.state.standards, item))}
/>
<DeferredSpinner loading={this.props.fetchingCwe} />
{this.props.cweOpen && (
<>
{this.renderCWESearch()}
{this.renderCWEList()}
{this.renderCWEHint()}
</>
)}
</FacetBox>
<ListStyleFacet<string>
className="is-inner"
facetHeader={translate('issues.facet.cwe')}
fetching={this.props.fetchingCwe}
getFacetItemText={item => renderCWECategory(this.state.standards, item)}
getSearchResultKey={item => item}
getSearchResultText={item => renderCWECategory(this.state.standards, item)}
loadSearchResultCount={this.loadCWESearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleCWESearch}
onToggle={this.props.onToggle}
open={this.props.cweOpen}
property="cwe"
query={omit(this.props.query, 'cwe')}
renderFacetItem={item => renderCWECategory(this.state.standards, item)}
renderSearchResult={(item, query) =>
highlightTerm(renderCWECategory(this.state.standards, item), query)
}
searchPlaceholder={translate('search.search_for_cwe')}
stats={this.props.cweStats}
values={this.props.cwe}
/>
</>
);
}

+ 0
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/StatusFacet.tsx View File

@@ -31,7 +31,6 @@ import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHi

interface Props {
fetching: boolean;
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
@@ -85,7 +84,6 @@ export default class StatusFacet extends React.PureComponent<Props> {
disabled={stat === 0 && !active}
halfWidth={true}
key={status}
loading={this.props.loading}
name={<StatusHelper resolution={undefined} status={status} />}
onClick={this.handleItemClick}
stat={formatFacetStat(stat)}

+ 10
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { omit } from 'lodash';
import { Query } from '../utils';
import { searchIssueTags } from '../../../api/issues';
import * as theme from '../../../app/theme';
@@ -30,11 +31,12 @@ import { highlightTerm } from '../../../helpers/search';
interface Props {
component: Component | undefined;
fetching: boolean;
loading?: boolean;
loadSearchResultCount: (changes: Partial<Query>) => Promise<number>;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
organization: string | undefined;
query: Query;
stats: { [x: string]: number } | undefined;
tags: string[];
}
@@ -54,6 +56,10 @@ export default class TagFacet extends React.PureComponent<Props> {
return tag;
};

loadSearchResultCount = (tag: string) => {
return this.props.loadSearchResultCount({ tags: [tag] });
};

renderTag = (tag: string) => {
return (
<>
@@ -72,17 +78,19 @@ export default class TagFacet extends React.PureComponent<Props> {

render() {
return (
<ListStyleFacet
<ListStyleFacet<string>
facetHeader={translate('issues.facet.tags')}
fetching={this.props.fetching}
getFacetItemText={this.getTagName}
getSearchResultKey={tag => tag}
getSearchResultText={tag => tag}
loadSearchResultCount={this.loadSearchResultCount}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="tags"
query={omit(this.props.query, 'tags')}
renderFacetItem={this.renderTag}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_tags')}

+ 0
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx View File

@@ -31,7 +31,6 @@ import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHi

interface Props {
fetching: boolean;
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
onToggle: (property: string) => void;
open: boolean;
@@ -94,7 +93,6 @@ export default class TypeFacet extends React.PureComponent<Props> {
active={active}
disabled={stat === 0 && !active}
key={type}
loading={this.props.loading}
name={
<span>
<IssueTypeIcon query={type} /> {translate('issue.type', type)}

+ 26
- 45
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx View File

@@ -20,56 +20,26 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import AssigneeFacet, { Props } from '../AssigneeFacet';
import { Query } from '../../utils';

jest.mock('../../../../store/rootReducer', () => ({}));

const renderAssigneeFacet = (props?: Partial<Props>) =>
shallow(
<AssigneeFacet
assigned={true}
assignees={[]}
component={undefined}
fetching={false}
onChange={jest.fn()}
onToggle={jest.fn()}
open={true}
organization={undefined}
referencedUsers={{ foo: { avatar: 'avatart-foo', name: 'name-foo' } }}
stats={{ '': 5, foo: 13, bar: 7, baz: 6 }}
{...props}
/>
);

it('should render', () => {
expect(renderAssigneeFacet()).toMatchSnapshot();
});

it('should render without stats', () => {
expect(renderAssigneeFacet({ stats: undefined })).toMatchSnapshot();
});

it('should select unassigned', () => {
expect(renderAssigneeFacet({ assigned: false })).toMatchSnapshot();
});

it('should select user', () => {
expect(renderAssigneeFacet({ assignees: ['foo'] })).toMatchSnapshot();
});

it('should render footer select option', () => {
const wrapper = renderAssigneeFacet();
it('should select unassigned', () => {
expect(
(wrapper.instance() as AssigneeFacet).renderOption({ avatar: 'avatar-foo', label: 'name-foo' })
).toMatchSnapshot();
renderAssigneeFacet({ assigned: false })
.find('ListStyleFacet')
.prop('values')
).toEqual(['']);
});

it('should call onChange', () => {
const onChange = jest.fn();
const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange });
const itemOnClick = wrapper
.find('FacetItem')
.first()
.prop<Function>('onClick');
const itemOnClick = wrapper.find('ListStyleFacet').prop<Function>('onItemClick');

itemOnClick('');
expect(onChange).lastCalledWith({ assigned: false, assignees: [] });
@@ -81,11 +51,22 @@ it('should call onChange', () => {
expect(onChange).lastCalledWith({ assigned: true, assignees: ['baz', 'foo'] });
});

it('should call onToggle', () => {
const onToggle = jest.fn();
const wrapper = renderAssigneeFacet({ onToggle });
const headerOnClick = wrapper.find('FacetHeader').prop<Function>('onClick');

headerOnClick();
expect(onToggle).lastCalledWith('assignees');
});
function renderAssigneeFacet(props?: Partial<Props>) {
return shallow(
<AssigneeFacet
assigned={true}
assignees={[]}
component={undefined}
fetching={false}
loadSearchResultCount={jest.fn()}
onChange={jest.fn()}
onToggle={jest.fn()}
open={true}
organization={undefined}
query={{} as Query}
referencedUsers={{ foo: { avatar: 'avatart-foo', name: 'name-foo' } }}
stats={{ '': 5, foo: 13, bar: 7, baz: 6 }}
{...props}
/>
);
}

+ 1
- 0
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-test.tsx View File

@@ -29,6 +29,7 @@ const renderSidebar = (props?: Partial<Props>) =>
<Sidebar
component={undefined}
facets={{}}
loadSearchResultCount={jest.fn()}
loadingFacets={{}}
myIssues={false}
onFacetToggle={jest.fn()}

+ 3
- 14
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx View File

@@ -21,6 +21,7 @@ import * as React from 'react';
import { shallow } from 'enzyme';
import StandardFacet, { Props } from '../StandardFacet';
import { click } from '../../../../helpers/testUtils';
import { Query } from '../../utils';

it('should render closed', () => {
expect(shallowRender()).toMatchSnapshot();
@@ -84,7 +85,6 @@ it('should select items', () => {
selectAndCheck('owaspTop10', 'a1');
selectAndCheck('owaspTop10', 'a1', true, ['a1', 'a3']);
selectAndCheck('sansTop25', 'foo');
selectAndCheck('cwe', '173');

function selectAndCheck(facet: string, value: string, multiple = false, expectedValue = [value]) {
wrapper
@@ -100,8 +100,6 @@ it('should toggle sub-facets', () => {
const wrapper = shallowRender({ onToggle, open: true });
click(wrapper.find('FacetBox[property="owaspTop10"]').children('FacetHeader'));
expect(onToggle).lastCalledWith('owaspTop10');
click(wrapper.find('FacetBox[property="cwe"]').children('FacetHeader'));
expect(onToggle).lastCalledWith('cwe');
click(wrapper.find('FacetBox[property="sansTop25"]').children('FacetHeader'));
expect(onToggle).lastCalledWith('sansTop25');
});
@@ -124,7 +122,6 @@ it('should display correct selection', () => {
'Unknown CWE'
]);
checkValues('owaspTop10', ['A1 - a1 title', 'A3', 'Not OWAPS']);
checkValues('cwe', ['CWE-42 - cwe-42 title', 'CWE-1111', 'Unknown CWE']);
checkValues('sansTop25', ['Risky Resource Management', 'foo']);

function checkValues(property: string, values: string[]) {
@@ -137,16 +134,6 @@ it('should display correct selection', () => {
}
});

it('should search CWE', () => {
const wrapper = shallowRender({ open: true, cwe: ['42'], cweOpen: true });
wrapper
.find('FacetBox[property="cwe"]')
.find('SearchBox')
.prop<Function>('onChange')('unkn');
wrapper.update();
expect(wrapper).toMatchSnapshot();
});

function shallowRender(props: Partial<Props> = {}) {
const wrapper = shallow(
<StandardFacet
@@ -156,12 +143,14 @@ function shallowRender(props: Partial<Props> = {}) {
fetchingCwe={false}
fetchingOwaspTop10={false}
fetchingSansTop25={false}
loadSearchResultCount={jest.fn()}
onChange={jest.fn()}
onToggle={jest.fn()}
open={false}
owaspTop10={[]}
owaspTop10Open={false}
owaspTop10Stats={{}}
query={{} as Query}
sansTop25={[]}
sansTop25Open={false}
sansTop25Stats={{}}

+ 33
- 315
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap View File

@@ -1,321 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render 1`] = `
<FacetBox
<ListStyleFacet
facetHeader="issues.facet.assignees"
fetching={false}
getFacetItemText={[Function]}
getSearchResultKey={[Function]}
getSearchResultText={[Function]}
getSortedItems={[Function]}
loadSearchResultCount={[Function]}
maxInitialItems={15}
maxItems={100}
onChange={[MockFunction]}
onClear={[Function]}
onItemClick={[Function]}
onSearch={[Function]}
onToggle={[MockFunction]}
open={true}
property="assignees"
>
<FacetHeader
name="issues.facet.assignees"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search.search_for_users"
value=""
/>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
loading={false}
name="unassigned"
onClick={[Function]}
stat="5"
tooltip="unassigned"
value=""
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="foo"
loading={false}
name={
<span>
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo
</span>
}
onClick={[Function]}
stat="13"
tooltip="name-foo"
value="foo"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="bar"
loading={false}
name="bar"
onClick={[Function]}
stat="7"
tooltip="bar"
value="bar"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="baz"
loading={false}
name="baz"
onClick={[Function]}
stat="6"
tooltip="baz"
value="baz"
/>
</FacetItemsList>
<MultipleSelectionHint
options={4}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should render footer select option 1`] = `
<span>
<Connect(Avatar)
className="little-spacer-right"
hash="avatar-foo"
name="name-foo"
size={16}
/>
name-foo
</span>
`;

exports[`should render without stats 1`] = `
<FacetBox
property="assignees"
>
<FacetHeader
name="issues.facet.assignees"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<MultipleSelectionHint
options={0}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should select unassigned 1`] = `
<FacetBox
property="assignees"
>
<FacetHeader
name="issues.facet.assignees"
onClear={[Function]}
onClick={[Function]}
open={true}
values={
Array [
"unassigned",
]
}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search.search_for_users"
value=""
/>
<FacetItemsList>
<FacetItem
active={true}
disabled={false}
halfWidth={false}
loading={false}
name="unassigned"
onClick={[Function]}
stat="5"
tooltip="unassigned"
value=""
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="foo"
loading={false}
name={
<span>
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo
</span>
}
onClick={[Function]}
stat="13"
tooltip="name-foo"
value="foo"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="bar"
loading={false}
name="bar"
onClick={[Function]}
stat="7"
tooltip="bar"
value="bar"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="baz"
loading={false}
name="baz"
onClick={[Function]}
stat="6"
tooltip="baz"
value="baz"
/>
</FacetItemsList>
<MultipleSelectionHint
options={4}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should select user 1`] = `
<FacetBox
property="assignees"
>
<FacetHeader
name="issues.facet.assignees"
onClear={[Function]}
onClick={[Function]}
open={true}
values={
Array [
"name-foo",
]
query={Object {}}
renderFacetItem={[Function]}
renderSearchResult={[Function]}
searchPlaceholder="search.search_for_users"
stats={
Object {
"": 5,
"bar": 7,
"baz": 6,
"foo": 13,
}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search.search_for_users"
value=""
/>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
loading={false}
name="unassigned"
onClick={[Function]}
stat="5"
tooltip="unassigned"
value=""
/>
<FacetItem
active={true}
disabled={false}
halfWidth={false}
key="foo"
loading={false}
name={
<span>
<Connect(Avatar)
className="little-spacer-right"
hash="avatart-foo"
name="name-foo"
size={16}
/>
name-foo
</span>
}
onClick={[Function]}
stat="13"
tooltip="name-foo"
value="foo"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="bar"
loading={false}
name="bar"
onClick={[Function]}
stat="7"
tooltip="bar"
value="bar"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="baz"
loading={false}
name="baz"
onClick={[Function]}
stat="6"
tooltip="baz"
value="baz"
/>
</FacetItemsList>
<MultipleSelectionHint
options={4}
values={1}
/>
</React.Fragment>
</FacetBox>
}
values={
Array [
"foo",
]
}
/>
`;

+ 28
- 157
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap View File

@@ -163,166 +163,37 @@ exports[`should render sub-facets 1`] = `
/>
</React.Fragment>
</FacetBox>
<FacetBox
className="is-inner"
property="cwe"
>
<FacetHeader
name="issues.facet.cwe"
onClick={[Function]}
open={true}
values={
Array [
"CWE-42 - cwe-42 title",
]
}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
onChange={[Function]}
placeholder="search.search_for_cwe"
value=""
/>
<FacetItemsList>
<FacetItem
active={true}
disabled={false}
halfWidth={false}
key="42"
loading={false}
name="CWE-42 - cwe-42 title"
onClick={[Function]}
stat="5"
tooltip="CWE-42 - cwe-42 title"
value="42"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="173"
loading={false}
name="CWE-173"
onClick={[Function]}
stat="3"
tooltip="CWE-173"
value="173"
/>
</FacetItemsList>
<MultipleSelectionHint
options={2}
values={1}
/>
</React.Fragment>
</FacetBox>
</React.Fragment>
</FacetBox>
`;

exports[`should search CWE 1`] = `
<FacetBox
property="standards"
>
<FacetHeader
name="issues.facet.standards"
onClear={[Function]}
onClick={[Function]}
open={true}
values={
Array [
"CWE-42 - cwe-42 title",
]
}
/>
<React.Fragment>
<FacetBox
className="is-inner"
property="owaspTop10"
>
<FacetHeader
name="issues.facet.owaspTop10"
onClick={[Function]}
open={false}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
</FacetBox>
<FacetBox
className="is-inner"
property="sansTop25"
>
<FacetHeader
name="issues.facet.sansTop25"
onClick={[Function]}
open={false}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
</FacetBox>
<FacetBox
<ListStyleFacet
className="is-inner"
facetHeader="issues.facet.cwe"
fetching={false}
getFacetItemText={[Function]}
getSearchResultKey={[Function]}
getSearchResultText={[Function]}
loadSearchResultCount={[Function]}
maxInitialItems={15}
maxItems={100}
onChange={[MockFunction]}
onSearch={[Function]}
onToggle={[MockFunction]}
open={true}
property="cwe"
>
<FacetHeader
name="issues.facet.cwe"
onClick={[Function]}
open={true}
values={
Array [
"CWE-42 - cwe-42 title",
]
query={Object {}}
renderFacetItem={[Function]}
renderSearchResult={[Function]}
searchPlaceholder="search.search_for_cwe"
stats={
Object {
"173": 3,
"42": 5,
}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
onChange={[Function]}
placeholder="search.search_for_cwe"
value="unkn"
/>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="unknown"
loading={false}
name={
<React.Fragment>
<mark>
Unkn
</mark>
own CWE
</React.Fragment>
}
onClick={[Function]}
tooltip="Unknown CWE"
value="unknown"
/>
</FacetItemsList>
<MultipleSelectionHint
options={0}
values={1}
/>
</React.Fragment>
</FacetBox>
}
values={
Array [
"42",
]
}
/>
</React.Fragment>
</FacetBox>
`;

+ 84
- 0
server/sonar-web/src/main/js/components/controls/MouseOverHandler.tsx View File

@@ -0,0 +1,84 @@
/*
* 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 { findDOMNode } from 'react-dom';

interface Props {
delay?: number;
onOver: () => void;
}

export default class MouseOverHandler extends React.Component<Props> {
mouseEnterInterval?: number;
mounted = false;

componentDidMount() {
this.mounted = true;

const node = this.getNode();
if (node) {
this.attachEvents(node);
}
}

componentWillUnmount() {
this.mounted = false;

const node = this.getNode();
if (node) {
this.detachEvents(node);
}
}

getNode = () => {
// eslint-disable-next-line react/no-find-dom-node
const node = findDOMNode(this);
return node && node instanceof Element ? node : undefined;
};

attachEvents = (node: Element) => {
node.addEventListener('mouseenter', this.handleMouseEnter);
node.addEventListener('mouseleave', this.handleMouseLeave);
};

detachEvents = (node: Element) => {
node.removeEventListener('mouseenter', this.handleMouseEnter);
node.removeEventListener('mouseleave', this.handleMouseLeave);
};

handleMouseEnter = () => {
this.mouseEnterInterval = window.setTimeout(() => {
if (this.mounted) {
this.props.onOver();
}
}, this.props.delay || 0);
};

handleMouseLeave = () => {
if (this.mouseEnterInterval !== undefined) {
window.clearInterval(this.mouseEnterInterval);
this.mouseEnterInterval = undefined;
}
};

render() {
return this.props.children;
}
}

+ 88
- 0
server/sonar-web/src/main/js/components/controls/__tests__/MouseOverHandler-test.tsx View File

@@ -0,0 +1,88 @@
/*
* 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 { mount } from 'enzyme';
import MouseOverHandler from '../MouseOverHandler';

jest.useFakeTimers();

it('should trigger after delay', () => {
const onOver = jest.fn();
const wrapper = mount(
<MouseOverHandler delay={1000} onOver={onOver}>
<div />
</MouseOverHandler>
);

const node = wrapper.getDOMNode();

event(node, 'mouseenter');
expect(onOver).not.toBeCalled();

jest.runTimersToTime(500);
expect(onOver).not.toBeCalled();

jest.runTimersToTime(1000);
expect(onOver).toBeCalled();
});

it('should not trigger when mouse is out', () => {
const onOver = jest.fn();
const wrapper = mount(
<MouseOverHandler delay={1000} onOver={onOver}>
<div />
</MouseOverHandler>
);

const node = wrapper.getDOMNode();

event(node, 'mouseenter');
expect(onOver).not.toBeCalled();

jest.runTimersToTime(500);
event(node, 'mouseleave');

jest.runTimersToTime(1000);
expect(onOver).not.toBeCalled();
});

it('should detach events', () => {
const onOver = jest.fn();
const wrapper = mount(
<MouseOverHandler delay={1000} onOver={onOver}>
<div />
</MouseOverHandler>
);

const node = wrapper.getDOMNode();

event(node, 'mouseenter');
expect(onOver).not.toBeCalled();

wrapper.unmount();

jest.runTimersToTime(1000);
expect(onOver).not.toBeCalled();
});

function event(node: Element, eventName: string) {
const event = new MouseEvent(eventName);
node.dispatchEvent(event);
}

+ 19
- 2
server/sonar-web/src/main/js/components/facet/FacetItem.tsx View File

@@ -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>
);
}

+ 39
- 0
server/sonar-web/src/main/js/components/facet/ListStyleFacet.css View File

@@ -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;
}

+ 130
- 31
server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx View File

@@ -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} />
</>
)}

+ 0
- 4
server/sonar-web/src/main/js/components/facet/__tests__/FacetItem-test.tsx View File

@@ -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();
});

+ 45
- 5
server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx View File

@@ -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);
}

+ 0
- 16
server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetItem-test.tsx.snap View File

@@ -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"

+ 75
- 55
server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap View File

@@ -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"

Loading…
Cancel
Save