Browse Source

SONAR-6400 Move the search box above the list of facet items (#592)

tags/7.5
Stas Vilchik 5 years ago
parent
commit
d4a017262d
28 changed files with 1492 additions and 781 deletions
  1. 15
    2
      server/sonar-web/src/main/js/api/components.ts
  2. 50
    28
      server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx
  3. 0
    63
      server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx
  4. 34
    18
      server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx
  5. 4
    3
      server/sonar-web/src/main/js/apps/issues/components/App.tsx
  6. 3
    1
      server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
  7. 150
    31
      server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
  8. 57
    101
      server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
  9. 78
    157
      server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
  10. 44
    109
      server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
  11. 2
    1
      server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
  12. 51
    22
      server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
  13. 37
    105
      server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
  14. 0
    9
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
  15. 5
    5
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx
  16. 27
    15
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
  17. 6
    6
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap
  18. 108
    23
      server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap
  19. 22
    18
      server/sonar-web/src/main/js/apps/issues/utils.ts
  20. 0
    38
      server/sonar-web/src/main/js/components/facet/FacetFooter.tsx
  21. 1
    1
      server/sonar-web/src/main/js/components/facet/FacetHeader.tsx
  22. 2
    6
      server/sonar-web/src/main/js/components/facet/FacetItem.tsx
  23. 272
    0
      server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx
  24. 144
    0
      server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx
  25. 0
    13
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetFooter-test.tsx.snap
  26. 8
    1
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap
  27. 360
    0
      server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
  28. 12
    5
      server/sonar-web/src/main/js/helpers/search.tsx

+ 15
- 2
server/sonar-web/src/main/js/api/components.ts View File

@@ -25,7 +25,8 @@ import {
BranchParameters,
MyProject,
Metric,
ComponentMeasure
ComponentMeasure,
LightComponent
} from '../app/types';

export interface BaseSearchProjectsParameters {
@@ -136,7 +137,19 @@ export function getComponent(
return getJSON('/api/measures/component', data).then(r => r.component);
}

export function getTree(component: string, options: RequestData = {}): Promise<any> {
export interface TreeComponent extends LightComponent {
id: string;
name: string;
refId?: string;
refKey?: string;
tags?: string[];
visibility: Visibility;
}

export function getTree(
component: string,
options: RequestData = {}
): Promise<{ baseComponent: TreeComponent; components: TreeComponent[]; paging: Paging }> {
return getJSON('/api/components/tree', { ...options, component });
}


+ 50
- 28
server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx View File

@@ -19,58 +19,80 @@
*/
import * as React from 'react';
import { connect } from 'react-redux';
import { uniq } from 'lodash';
import Facet, { BasicProps } from './Facet';
import LanguageFacetFooter from './LanguageFacetFooter';
import { uniqBy } from 'lodash';
import { BasicProps } from './Facet';
import { getLanguages } from '../../../store/rootReducer';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { translate } from '../../../helpers/l10n';
import { highlightTerm } from '../../../helpers/search';

interface InstalledLanguage {
key: string;
name: string;
}

interface StateProps {
referencedLanguages: { [language: string]: { key: string; name: string } };
installedLanguages: InstalledLanguage[];
}

interface Props extends BasicProps, StateProps {}

class LanguageFacet extends React.PureComponent<Props> {
getLanguageName = (language: string) => {
const { referencedLanguages } = this.props;
return referencedLanguages[language] ? referencedLanguages[language].name : language;
getLanguageName = (languageKey: string) => {
const language = this.props.installedLanguages.find(l => l.key === languageKey);
return language ? language.name : languageKey;
};

handleSelect = (language: string) => {
const { values } = this.props;
this.props.onChange({ languages: uniq([...values, language]) });
handleSearch = (query: string) => {
const options = this.getAllPossibleOptions();
const results = options.filter(language =>
language.name.toLowerCase().includes(query.toLowerCase())
);
const paging = { pageIndex: 1, pageSize: results.length, total: results.length };
return Promise.resolve({ paging, results });
};

renderFooter = () => {
if (!this.props.stats) {
return null;
}
getAllPossibleOptions = () => {
const { installedLanguages, stats = {} } = this.props;

return (
<LanguageFacetFooter
onSelect={this.handleSelect}
referencedLanguages={this.props.referencedLanguages}
selected={Object.keys(this.props.stats)}
/>
// add any language that presents in the facet, but might not be installed
// for such language we don't know their display name, so let's just use their key
// and make sure we reference each language only once
return uniqBy(
[...installedLanguages, ...Object.keys(stats).map(key => ({ key, name: key }))],
language => language.key
);
};

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

render() {
const { referencedLanguages, ...facetProps } = this.props;
return (
<Facet
{...facetProps}
<ListStyleFacet
facetHeader={translate('coding_rules.facet.languages')}
fetching={false}
getFacetItemText={this.getLanguageName}
getSearchResultKey={(language: InstalledLanguage) => language.key}
getSearchResultText={(language: InstalledLanguage) => language.name}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="languages"
renderFooter={this.renderFooter}
renderName={this.getLanguageName}
renderTextName={this.getLanguageName}
renderFacetItem={this.getLanguageName}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_languages')}
stats={this.props.stats}
values={this.props.values}
/>
);
}
}

const mapStateToProps = (state: any): StateProps => ({
referencedLanguages: getLanguages(state)
const mapStateToProps = (state: any) => ({
installedLanguages: Object.values(getLanguages(state))
});

export default connect(mapStateToProps)(LanguageFacet);

+ 0
- 63
server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx View File

@@ -1,63 +0,0 @@
/*
* 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 { difference } from 'lodash';
import Select from '../../../components/controls/Select';
import { translate } from '../../../helpers/l10n';

type Option = { label: string; value: string };

interface Props {
referencedLanguages: { [language: string]: { key: string; name: string } };
onSelect: (value: string) => void;
selected: string[];
}

export default class LanguageFacetFooter extends React.PureComponent<Props> {
handleChange = (option: Option) => this.props.onSelect(option.value);

render() {
const options = difference(
Object.keys(this.props.referencedLanguages),
this.props.selected
).map(key => ({
label: this.props.referencedLanguages[key].name,
value: key
}));

if (options.length === 0) {
return null;
}

return (
<div className="search-navigator-facet-footer">
<Select
className="input-super-large"
clearable={false}
noResultsText={translate('select2.noMatches')}
onChange={this.handleChange}
options={options}
placeholder={translate('search.search_for_languages')}
searchable={true}
/>
</div>
);
}
}

+ 34
- 18
server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx View File

@@ -19,11 +19,13 @@
*/
import * as React from 'react';
import { uniq } from 'lodash';
import Facet, { BasicProps } from './Facet';
import { BasicProps } from './Facet';
import { getRuleTags } from '../../../api/rules';
import * as theme from '../../../app/theme';
import FacetFooter from '../../../components/facet/FacetFooter';
import TagsIcon from '../../../components/icons-components/TagsIcon';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { translate } from '../../../helpers/l10n';
import { highlightTerm } from '../../../helpers/search';

interface Props extends BasicProps {
organization: string | undefined;
@@ -31,38 +33,52 @@ interface Props extends BasicProps {

export default class TagFacet extends React.PureComponent<Props> {
handleSearch = (query: string) => {
return getRuleTags({ organization: this.props.organization, ps: 50, q: query }).then(tags =>
tags.map(tag => ({ label: tag, value: tag }))
);
return getRuleTags({ organization: this.props.organization, ps: 50, q: query }).then(tags => ({
paging: { pageIndex: 1, pageSize: tags.length, total: tags.length },
results: tags
}));
};

handleSelect = (option: { value: string }) => {
this.props.onChange({ tags: uniq([...this.props.values, option.value]) });
};

renderName = (tag: string) => (
getTagName = (tag: string) => {
return tag;
};

renderTag = (tag: string) => (
<>
<TagsIcon className="little-spacer-right" fill={theme.gray60} />
{tag}
</>
);

renderFooter = () => {
if (!this.props.stats) {
return null;
}

return <FacetFooter onSearch={this.handleSearch} onSelect={this.handleSelect} />;
};
renderSearchResult = (tag: string, term: string) => (
<>
<TagsIcon className="little-spacer-right" fill={theme.gray60} />
{highlightTerm(tag, term)}
</>
);

render() {
const { organization, ...facetProps } = this.props;
return (
<Facet
{...facetProps}
<ListStyleFacet
facetHeader={translate('coding_rules.facet.tags')}
fetching={false}
getFacetItemText={this.getTagName}
getSearchResultKey={tag => tag}
getSearchResultText={tag => tag}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="tags"
renderFooter={this.renderFooter}
renderName={this.renderName}
renderFacetItem={this.renderTag}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_tags')}
stats={this.props.stats}
values={this.props.values}
/>
);
}

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

@@ -50,7 +50,8 @@ import {
ReferencedUser,
saveMyIssues,
serializeQuery,
STANDARDS
STANDARDS,
ReferencedRule
} from '../utils';
import {
Component,
@@ -90,7 +91,7 @@ interface FetchIssuesPromise {
issues: Issue[];
languages: ReferencedLanguage[];
paging: Paging;
rules: { name: string }[];
rules: ReferencedRule[];
users: ReferencedUser[];
}

@@ -125,7 +126,7 @@ export interface State {
query: Query;
referencedComponents: { [componentKey: string]: ReferencedComponent };
referencedLanguages: { [languageKey: string]: ReferencedLanguage };
referencedRules: { [ruleKey: string]: { name: string } };
referencedRules: { [ruleKey: string]: ReferencedRule };
referencedUsers: { [login: string]: ReferencedUser };
selected?: string;
selectedFlowIndex?: number;

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

@@ -155,7 +155,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
};

handleAssigneeSearch = (query: string) => {
return searchAssignees(query, this.state.organization);
return searchAssignees(query, this.state.organization).then(({ results }) =>
results.map(r => ({ avatar: r.avatar, label: r.name, value: r.login }))
);
};

handleAssigneeSelect = (assignee: AssigneeOption) => {

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

@@ -19,10 +19,15 @@
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import { searchAssignees, formatFacetStat, Query, ReferencedUser } from '../utils';
import { Component } from '../../../app/types';
import {
searchAssignees,
formatFacetStat,
Query,
ReferencedUser,
SearchedAssignee
} from '../utils';
import { Component, Paging } from '../../../app/types';
import FacetBox from '../../../components/facet/FacetBox';
import FacetFooter from '../../../components/facet/FacetFooter';
import FacetHeader from '../../../components/facet/FacetHeader';
import FacetItem from '../../../components/facet/FacetItem';
import FacetItemsList from '../../../components/facet/FacetItemsList';
@@ -30,6 +35,9 @@ 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';

export interface Props {
assigned: boolean;
@@ -45,11 +53,66 @@ export interface Props {
referencedUsers: { [login: string]: ReferencedUser };
}

export default class AssigneeFacet extends React.PureComponent<Props> {
interface State {
query: string;
searching: boolean;
searchResults?: SearchedAssignee[];
searchPaging?: Paging;
}

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

static defaultProps = {
open: true
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
);
}
};

handleItemClick = (itemValue: string, multiple: boolean) => {
@@ -78,10 +141,6 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
this.props.onChange({ assigned: true, assignees: [] });
};

handleSearch = (query: string) => {
return searchAssignees(query, this.props.organization);
};

handleSelect = (option: { value: string }) => {
const { assignees } = this.props;
this.props.onChange({ assigned: true, [this.property]: uniq([...assignees, option.value]) });
@@ -134,21 +193,18 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
}

renderOption = (option: { avatar: string; label: string }) => {
return (
<span>
{option.avatar !== undefined && (
<Avatar
className="little-spacer-right"
hash={option.avatar}
name={option.label}
size={16}
/>
)}
{option.label}
</span>
);
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 (
@@ -185,16 +241,77 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
);
}

renderFooter() {
if (!this.props.stats) {
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;
}

return (
<FacetFooter
onSearch={this.handleSearch}
onSelect={this.handleSelect}
renderOption={this.renderOption}
<>
<FacetItemsList>
{searchResults.map(result => this.renderSearchResult(result))}
</FacetItemsList>
<ListFooter
count={searchResults.length}
loadMore={this.searchMore}
ready={!searching}
total={searchPaging.total}
/>
</>
);
}

renderSearchResult(result: SearchedAssignee) {
const active = this.props.assignees.includes(result.login);
const stat = this.getStat(result.login);
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}
/>
);
}
@@ -214,8 +331,10 @@ export default class AssigneeFacet extends React.PureComponent<Props> {
<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderList()}
{this.renderFooter()}
{this.renderSearch()}
{this.state.query && this.state.searchResults !== undefined
? this.renderSearchResults()
: this.renderList()}
<MultipleSelectionHint options={Object.keys(stats).length} values={assignees.length} />
</>
)}

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

@@ -18,19 +18,22 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import LanguageFacetFooter from './LanguageFacetFooter';
import { formatFacetStat, Query, ReferencedLanguage } from '../utils';
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 { uniqBy } from 'lodash';
import { connect } from 'react-redux';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedLanguage } from '../utils';
import { getLanguages } from '../../../store/rootReducer';
import { translate } from '../../../helpers/l10n';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
import { highlightTerm } from '../../../helpers/search';

interface InstalledLanguage {
key: string;
name: string;
}

interface Props {
fetching: boolean;
installedLanguages: InstalledLanguage[];
languages: string[];
loading?: boolean;
onChange: (changes: Partial<Query>) => void;
@@ -40,109 +43,62 @@ interface Props {
stats: { [x: string]: number } | undefined;
}

export default class LanguageFacet extends React.PureComponent<Props> {
property = 'languages';

static defaultProps = {
open: true
};

handleItemClick = (itemValue: string, multiple: boolean) => {
const { languages } = this.props;
if (multiple) {
const newValue = sortBy(
languages.includes(itemValue) ? without(languages, itemValue) : [...languages, itemValue]
);
this.props.onChange({ [this.property]: newValue });
} else {
this.props.onChange({
[this.property]: languages.includes(itemValue) && languages.length < 2 ? [] : [itemValue]
});
}
};

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

handleClear = () => {
this.props.onChange({ [this.property]: [] });
};

getLanguageName(language: string) {
class LanguageFacet extends React.PureComponent<Props> {
getLanguageName = (language: string) => {
const { referencedLanguages } = this.props;
return referencedLanguages[language] ? referencedLanguages[language].name : language;
}

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

handleSelect = (language: string) => {
const { languages } = this.props;
this.props.onChange({ [this.property]: uniq([...languages, language]) });
};

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

if (!stats) {
return null;
}

const languages = sortBy(Object.keys(stats), key => -stats[key]);

return (
<FacetItemsList>
{languages.map(language => (
<FacetItem
active={this.props.languages.includes(language)}
key={language}
loading={this.props.loading}
name={this.getLanguageName(language)}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(language))}
tooltip={this.getLanguageName(language)}
value={language}
/>
))}
</FacetItemsList>
handleSearch = (query: string) => {
const options = this.getAllPossibleOptions();
const results = options.filter(language =>
language.name.toLowerCase().includes(query.toLowerCase())
);
}
const paging = { pageIndex: 1, pageSize: results.length, total: results.length };
return Promise.resolve({ paging, results });
};

renderFooter() {
if (!this.props.stats) {
return null;
}
getAllPossibleOptions = () => {
const { installedLanguages, stats = {} } = this.props;

return (
<LanguageFacetFooter onSelect={this.handleSelect} selected={Object.keys(this.props.stats)} />
// add any language that presents in the facet, but might not be installed
// for such language we don't know their display name, so let's just use their key
// and make sure we reference each language only once
return uniqBy(
[...installedLanguages, ...Object.keys(stats).map(key => ({ key, name: key }))],
language => language.key
);
}
};

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

render() {
const { languages, stats = {} } = this.props;
const values = this.props.languages.map(language => this.getLanguageName(language));
return (
<FacetBox property={this.property}>
<FacetHeader
name={translate('issues.facet', this.property)}
onClear={this.handleClear}
onClick={this.handleHeaderClick}
open={this.props.open}
values={values}
/>

<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderList()}
{this.renderFooter()}
<MultipleSelectionHint options={Object.keys(stats).length} values={languages.length} />
</>
)}
</FacetBox>
<ListStyleFacet
facetHeader={translate('issues.facet.languages')}
fetching={this.props.fetching}
getFacetItemText={this.getLanguageName}
getSearchResultKey={(language: InstalledLanguage) => language.key}
getSearchResultText={(language: InstalledLanguage) => language.name}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="languages"
renderFacetItem={this.getLanguageName}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_languages')}
stats={this.props.stats}
values={this.props.languages}
/>
);
}
}

const mapStateToProps = (state: any) => ({
installedLanguages: Object.values(getLanguages(state))
});

export default connect(mapStateToProps)(LanguageFacet);

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

@@ -18,20 +18,14 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import { formatFacetStat, Query, ReferencedComponent } from '../utils';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedComponent } from '../utils';
import { searchProjects, getTree } from '../../../api/components';
import { Component } 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 FacetFooter from '../../../components/facet/FacetFooter';
import { Component, Paging } from '../../../app/types';
import Organization from '../../../components/shared/Organization';
import QualifierIcon from '../../../components/icons-components/QualifierIcon';
import { translate } from '../../../helpers/l10n';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
import { highlightTerm } from '../../../helpers/search';

interface Props {
component: Component | undefined;
@@ -46,177 +40,104 @@ interface Props {
stats: { [x: string]: number } | undefined;
}

export default class ProjectFacet extends React.PureComponent<Props> {
property = 'projects';

static defaultProps = {
open: true
};

handleItemClick = (itemValue: string, multiple: boolean) => {
const { projects } = this.props;
if (multiple) {
const newValue = sortBy(
projects.includes(itemValue) ? without(projects, itemValue) : [...projects, itemValue]
);
this.props.onChange({ [this.property]: newValue });
} else {
this.props.onChange({
[this.property]: projects.includes(itemValue) && projects.length < 2 ? [] : [itemValue]
});
}
};

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

handleClear = () => {
this.props.onChange({ [this.property]: [] });
};
interface SearchedProject {
id: string;
name: string;
organization: string;
}

handleSearch = (query: string) => {
export default class ProjectFacet extends React.PureComponent<Props> {
handleSearch = (
query: string,
page = 1
): Promise<{ results: SearchedProject[]; paging: Paging }> => {
const { component, organization } = this.props;
if (component && ['VW', 'SVW', 'APP'].includes(component.qualifier)) {
return getTree(component.key, { ps: 50, q: query, qualifiers: 'TRK' }).then(response =>
response.components.map((component: any) => ({
label: component.name,
organization: component.organization,
value: component.refId
}))
return getTree(component.key, { p: page, ps: 30, q: query, qualifiers: 'TRK' }).then(
({ components, paging }) => ({
paging,
results: components.map(component => ({
id: component.refId || component.id,
key: component.key,
name: component.name,
organization: component.organization
}))
})
);
}

return searchProjects({
ps: 50,
p: page,
ps: 30,
filter: query ? `query = "${query}"` : '',
organization: organization && organization.key
}).then(response =>
response.components.map(component => ({
label: component.name,
organization: component.organization,
value: component.id
}).then(({ components, paging }) => ({
paging,
results: components.map(component => ({
id: component.id,
key: component.key,
name: component.name,
organization: component.organization
}))
);
};

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

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

getProjectName(project: string) {
getProjectName = (project: string) => {
const { referencedComponents } = this.props;
return referencedComponents[project] ? referencedComponents[project].name : project;
}

getProjectNameAndTooltip(project: string) {
const { organization, referencedComponents } = this.props;
return referencedComponents[project]
? {
name: (
<span>
<QualifierIcon className="little-spacer-right" qualifier="TRK" />
{!organization && (
<Organization
link={false}
organizationKey={referencedComponents[project].organization}
/>
)}
{referencedComponents[project].name}
</span>
),
tooltip: referencedComponents[project].name
}
: {
name: (
<span>
<QualifierIcon className="little-spacer-right" qualifier="TRK" />
{project}
</span>
),
tooltip: project
};
}
};

renderOption = (option: { label: string; organization: string }) => {
return (
renderFacetItem = (project: string) => {
const { referencedComponents } = this.props;
return referencedComponents[project] ? (
this.renderProject(referencedComponents[project])
) : (
<span>
<Organization link={false} organizationKey={option.organization} />
{option.label}
<QualifierIcon className="little-spacer-right" qualifier="TRK" />
{project}
</span>
);
};

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

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

if (!stats) {
return null;
}

const projects = sortBy(Object.keys(stats), key => -stats[key]);

return <FacetItemsList>{projects.map(project => this.renderListItem(project))}</FacetItemsList>;
}

renderFooter() {
if (!this.props.stats) {
return null;
}
renderProject = (project: Pick<SearchedProject, 'name' | 'organization'>) => (
<span>
<QualifierIcon className="little-spacer-right" qualifier="TRK" />
{!this.props.organization && (
<Organization link={false} organizationKey={project.organization} />
)}
{project.name}
</span>
);

renderSearchResult = (project: Pick<SearchedProject, 'name' | 'organization'>, term: string) => (
<>
<QualifierIcon className="little-spacer-right" qualifier="TRK" />
{!this.props.organization && (
<Organization link={false} organizationKey={project.organization} />
)}
{highlightTerm(project.name, term)}
</>
);

render() {
return (
<FacetFooter
minimumQueryLength={3}
<ListStyleFacet
facetHeader={translate('issues.facet.projects')}
fetching={this.props.fetching}
getFacetItemText={this.getProjectName}
getSearchResultKey={(project: SearchedProject) => project.id}
getSearchResultText={(project: SearchedProject) => project.name}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onSelect={this.handleSelect}
renderOption={this.renderOption}
onToggle={this.props.onToggle}
open={this.props.open}
property="projects"
renderFacetItem={this.renderFacetItem}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_projects')}
stats={this.props.stats}
values={this.props.projects}
/>
);
}

render() {
const { projects, stats = {} } = this.props;
const values = this.props.projects.map(project => this.getProjectName(project));
return (
<FacetBox property={this.property}>
<FacetHeader
name={translate('issues.facet', this.property)}
onClear={this.handleClear}
onClick={this.handleHeaderClick}
open={this.props.open}
values={values}
/>
<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderList()}
{this.renderFooter()}
<MultipleSelectionHint options={Object.keys(stats).length} values={projects.length} />
</>
)}
</FacetBox>
);
}
}

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

@@ -18,17 +18,11 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import { formatFacetStat, Query } from '../utils';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { Query, ReferencedRule } from '../utils';
import { searchRules } from '../../../api/rules';
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 FacetFooter from '../../../components/facet/FacetFooter';
import { Rule, Paging } from '../../../app/types';
import { translate } from '../../../helpers/l10n';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';

interface Props {
fetching: boolean;
@@ -38,126 +32,67 @@ interface Props {
onToggle: (property: string) => void;
open: boolean;
organization: string | undefined;
referencedRules: { [ruleKey: string]: { name: string } };
referencedRules: { [ruleKey: string]: ReferencedRule };
rules: string[];
stats: { [x: string]: number } | undefined;
}

export default class RuleFacet extends React.PureComponent<Props> {
property = 'rules';

static defaultProps = {
open: true
};

handleItemClick = (itemValue: string, multiple: boolean) => {
const { rules } = this.props;
if (multiple) {
const newValue = sortBy(
rules.includes(itemValue) ? without(rules, itemValue) : [...rules, itemValue]
);
this.props.onChange({ [this.property]: newValue });
} else {
this.props.onChange({
[this.property]: rules.includes(itemValue) && rules.length < 2 ? [] : [itemValue]
});
}
};

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

handleClear = () => {
this.props.onChange({ [this.property]: [] });
};
interface State {
query: string;
searching: boolean;
searchResults?: Rule[];
searchPaging?: Paging;
}

handleSearch = (query: string) => {
export default class RuleFacet extends React.PureComponent<Props, State> {
handleSearch = (query: string, page = 1) => {
const { languages, organization } = this.props;
return searchRules({
f: 'name,langName',
languages: languages.length ? languages.join() : undefined,
organization,
q: query,
p: page,
ps: 30,
s: 'name',
// eslint-disable-next-line camelcase
include_external: true
}).then(response =>
response.rules.map(rule => ({ label: `(${rule.langName}) ${rule.name}`, value: rule.key }))
);
}).then(response => ({
paging: { pageIndex: response.p, pageSize: response.ps, total: response.total },
results: response.rules
}));
};

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

getRuleName(rule: string): string {
getRuleName = (rule: string) => {
const { referencedRules } = this.props;
return referencedRules[rule] ? referencedRules[rule].name : rule;
}

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

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

if (!stats) {
return null;
}

const rules = sortBy(Object.keys(stats), key => -stats[key], key => this.getRuleName(key));

return (
<FacetItemsList>
{rules.map(rule => (
<FacetItem
active={this.props.rules.includes(rule)}
key={rule}
loading={this.props.loading}
name={this.getRuleName(rule)}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(rule))}
tooltip={this.getRuleName(rule)}
value={rule}
/>
))}
</FacetItemsList>
);
}

renderFooter() {
if (!this.props.stats) {
return null;
}
return referencedRules[rule]
? `(${referencedRules[rule].langName}) ${referencedRules[rule].name}`
: rule;
};

return <FacetFooter onSearch={this.handleSearch} onSelect={this.handleSelect} />;
}
renderSearchResult = (rule: Rule) => {
return `(${rule.langName}) ${rule.name}`;
};

render() {
const { rules, stats = {} } = this.props;
const values = rules.map(rule => this.getRuleName(rule));
return (
<FacetBox property={this.property}>
<FacetHeader
name={translate('issues.facet', this.property)}
onClear={this.handleClear}
onClick={this.handleHeaderClick}
open={this.props.open}
values={values}
/>

<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderList()}
{this.renderFooter()}
<MultipleSelectionHint options={Object.keys(stats).length} values={rules.length} />
</>
)}
</FacetBox>
<ListStyleFacet
facetHeader={translate('issues.facet.rules')}
fetching={this.props.fetching}
getFacetItemText={this.getRuleName}
getSearchResultKey={result => result.key}
getSearchResultText={result => result.name}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="rules"
renderFacetItem={this.getRuleName}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_rules')}
stats={this.props.stats}
values={this.props.rules}
/>
);
}
}

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

@@ -39,6 +39,7 @@ import {
ReferencedComponent,
ReferencedUser,
ReferencedLanguage,
ReferencedRule,
STANDARDS
} from '../utils';
import { Component } from '../../../app/types';
@@ -57,7 +58,7 @@ export interface Props {
query: Query;
referencedComponents: { [componentKey: string]: ReferencedComponent };
referencedLanguages: { [languageKey: string]: ReferencedLanguage };
referencedRules: { [ruleKey: string]: { name: string } };
referencedRules: { [ruleKey: string]: ReferencedRule };
referencedUsers: { [login: string]: ReferencedUser };
}


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

@@ -25,7 +25,6 @@ import FacetHeader from '../../../components/facet/FacetHeader';
import { translate } from '../../../helpers/l10n';
import FacetItemsList from '../../../components/facet/FacetItemsList';
import FacetItem from '../../../components/facet/FacetItem';
import Select from '../../../components/controls/Select';
import {
renderOwaspTop10Category,
renderSansTop25Category,
@@ -34,6 +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';

export interface Props {
cwe: string[];
@@ -55,6 +56,7 @@ export interface Props {
}

interface State {
cweQuery: string;
standards: Standards;
}

@@ -64,7 +66,10 @@ type ValuesProp = 'owaspTop10' | 'sansTop25' | 'cwe';
export default class StandardFacet extends React.PureComponent<Props, State> {
mounted = false;
property = STANDARDS;
state: State = { standards: { owaspTop10: {}, sansTop25: {}, cwe: {} } };
state: State = {
cweQuery: '',
standards: { owaspTop10: {}, sansTop25: {}, cwe: {} }
};

componentDidMount() {
this.mounted = true;
@@ -165,6 +170,10 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
this.handleItemClick('cwe', value, true);
};

handleCWESearch = (query: string) => {
this.setState({ cweQuery: query });
};

renderList = (
statsProp: StatsProp,
valuesProp: ValuesProp,
@@ -173,13 +182,22 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
) => {
const stats = this.props[statsProp];
const values = this.props[valuesProp];

if (!stats) {
return null;
}

const categories = sortBy(Object.keys(stats), key => -stats[key]);
return this.renderFacetItemsList(stats, values, categories, renderName, renderName, onClick);
};

// eslint-disable-next-line max-params
renderFacetItemsList = (
stats: any,
values: string[],
categories: string[],
renderName: (standards: Standards, category: string) => React.ReactNode,
renderTooltip: (standards: Standards, category: string) => string,
onClick: (x: string, multiple?: boolean) => void
) => {
if (!categories.length) {
return (
<div className="search-navigator-facet-empty little-spacer-top">
@@ -202,7 +220,7 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
name={renderName(this.state.standards, category)}
onClick={onClick}
stat={formatFacetStat(getStat(category))}
tooltip={renderName(this.state.standards, category)}
tooltip={renderTooltip(this.state.standards, category)}
value={category}
/>
))}
@@ -230,26 +248,37 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
}

renderCWEList() {
return this.renderList('cweStats', 'cwe', renderCWECategory, this.handleCWEItemClick);
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() {
const options = Object.keys(this.state.standards.cwe).map(cwe => ({
label: renderCWECategory(this.state.standards, cwe),
value: cwe
}));
return (
<div className="search-navigator-facet-footer">
<Select
className="input-super-large"
clearable={false}
noResultsText={translate('select2.noMatches')}
onChange={this.handleCWESelect}
options={options}
placeholder={translate('search.search_for_cwe')}
searchable={true}
/>
</div>
<SearchBox
autoFocus={true}
className="little-spacer-top spacer-bottom"
onChange={this.handleCWESearch}
placeholder={translate('search.search_for_cwe')}
value={this.state.cweQuery}
/>
);
}

@@ -317,8 +346,8 @@ export default class StandardFacet extends React.PureComponent<Props, State> {
<DeferredSpinner loading={this.props.fetchingCwe} />
{this.props.cweOpen && (
<>
{this.renderCWEList()}
{this.renderCWESearch()}
{this.renderCWEList()}
{this.renderCWEHint()}
</>
)}

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

@@ -18,20 +18,14 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { sortBy, uniq, without } from 'lodash';
import { formatFacetStat, Query } from '../utils';
import { Query } from '../utils';
import { searchIssueTags } from '../../../api/issues';
import * as theme from '../../../app/theme';
import { Component } from '../../../app/types';
import FacetBox from '../../../components/facet/FacetBox';
import FacetFooter from '../../../components/facet/FacetFooter';
import FacetHeader from '../../../components/facet/FacetHeader';
import FacetItem from '../../../components/facet/FacetItem';
import FacetItemsList from '../../../components/facet/FacetItemsList';
import TagsIcon from '../../../components/icons-components/TagsIcon';
import { translate } from '../../../helpers/l10n';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint';
import ListStyleFacet from '../../../components/facet/ListStyleFacet';
import { highlightTerm } from '../../../helpers/search';

interface Props {
component: Component | undefined;
@@ -46,116 +40,54 @@ interface Props {
}

export default class TagFacet extends React.PureComponent<Props> {
property = 'tags';

static defaultProps = {
open: true
};

handleItemClick = (itemValue: string, multiple: boolean) => {
const { tags } = this.props;
if (multiple) {
const { tags } = this.props;
const newValue = sortBy(
tags.includes(itemValue) ? without(tags, itemValue) : [...tags, itemValue]
);
this.props.onChange({ [this.property]: newValue });
} else {
this.props.onChange({
[this.property]: tags.includes(itemValue) && tags.length < 2 ? [] : [itemValue]
});
}
};

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

handleClear = () => {
this.props.onChange({ [this.property]: [] });
};

handleSearch = (query: string) => {
return searchIssueTags({ organization: this.props.organization, ps: 50, q: query }).then(tags =>
tags.map(tag => ({ label: tag, value: tag }))
return searchIssueTags({ organization: this.props.organization, ps: 50, q: query }).then(
tags => ({
paging: { pageIndex: 1, pageSize: tags.length, total: tags.length },
results: tags
})
);
};

handleSelect = (option: { value: string }) => {
const { tags } = this.props;
this.props.onChange({ [this.property]: uniq([...tags, option.value]) });
getTagName = (tag: string) => {
return tag;
};

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

renderTag(tag: string) {
renderTag = (tag: string) => {
return (
<span>
<>
<TagsIcon className="little-spacer-right" fill={theme.gray60} />
{tag}
</span>
</>
);
}

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

if (!stats) {
return null;
}

const tags = sortBy(Object.keys(stats), key => -stats[key]);

return (
<FacetItemsList>
{tags.map(tag => (
<FacetItem
active={this.props.tags.includes(tag)}
key={tag}
loading={this.props.loading}
name={this.renderTag(tag)}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(tag))}
tooltip={tag}
value={tag}
/>
))}
</FacetItemsList>
);
}
};

renderFooter() {
if (!this.props.stats) {
return null;
}

return <FacetFooter onSearch={this.handleSearch} onSelect={this.handleSelect} />;
}
renderSearchResult = (tag: string, term: string) => (
<>
<TagsIcon className="little-spacer-right" fill={theme.gray60} />
{highlightTerm(tag, term)}
</>
);

render() {
const { tags, 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.props.tags}
/>

<DeferredSpinner loading={this.props.fetching} />
{this.props.open && (
<>
{this.renderList()}
{this.renderFooter()}
<MultipleSelectionHint options={Object.keys(stats).length} values={tags.length} />
</>
)}
</FacetBox>
<ListStyleFacet
facetHeader={translate('issues.facet.tags')}
fetching={this.props.fetching}
getFacetItemText={this.getTagName}
getSearchResultKey={tag => tag}
getSearchResultText={tag => tag}
onChange={this.props.onChange}
onSearch={this.handleSearch}
onToggle={this.props.onToggle}
open={this.props.open}
property="tags"
renderFacetItem={this.renderTag}
renderSearchResult={this.renderSearchResult}
searchPlaceholder={translate('search.search_for_tags')}
stats={this.props.stats}
values={this.props.tags}
/>
);
}
}

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

@@ -89,12 +89,3 @@ it('should call onToggle', () => {
headerOnClick();
expect(onToggle).lastCalledWith('assignees');
});

it('should handle footer callbacks', () => {
const onChange = jest.fn();
const wrapper = renderAssigneeFacet({ assignees: ['foo'], onChange });
const onSelect = wrapper.find('FacetFooter').prop<Function>('onSelect');

onSelect({ value: 'qux' });
expect(onChange).lastCalledWith({ assigned: true, assignees: ['foo', 'qux'] });
});

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

@@ -138,13 +138,13 @@ it('should display correct selection', () => {
});

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

function shallowRender(props: Partial<Props> = {}) {

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

@@ -16,6 +16,15 @@ exports[`should render 1`] = `
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}
@@ -75,11 +84,6 @@ exports[`should render 1`] = `
value="baz"
/>
</FacetItemsList>
<FacetFooter
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
/>
<MultipleSelectionHint
options={4}
values={0}
@@ -144,6 +148,15 @@ exports[`should select unassigned 1`] = `
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}
@@ -203,11 +216,6 @@ exports[`should select unassigned 1`] = `
value="baz"
/>
</FacetItemsList>
<FacetFooter
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
/>
<MultipleSelectionHint
options={4}
values={0}
@@ -236,6 +244,15 @@ exports[`should select user 1`] = `
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}
@@ -295,11 +312,6 @@ exports[`should select user 1`] = `
value="baz"
/>
</FacetItemsList>
<FacetFooter
onSearch={[Function]}
onSelect={[Function]}
renderOption={[Function]}
/>
<MultipleSelectionHint
options={4}
values={1}

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

@@ -7,7 +7,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",
@@ -26,7 +26,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",
@@ -43,7 +43,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",
@@ -60,7 +60,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",
@@ -79,7 +79,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",
@@ -98,7 +98,7 @@ Array [
"ResolutionFacet",
"StatusFacet",
"CreationDateFacet",
"LanguageFacet",
"Connect(LanguageFacet)",
"RuleFacet",
"StandardFacet",
"TagFacet",

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

@@ -182,6 +182,13 @@ exports[`should render sub-facets 1`] = `
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}
@@ -208,32 +215,110 @@ exports[`should render sub-facets 1`] = `
value="173"
/>
</FacetItemsList>
<div
className="search-navigator-facet-footer"
>
<Select
className="input-super-large"
clearable={false}
noResultsText="select2.noMatches"
onChange={[Function]}
options={
Array [
Object {
"label": "CWE-42 - cwe-42 title",
"value": "42",
},
Object {
"label": "Unknown CWE",
"value": "unknown",
},
]
<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
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="unkn"
/>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="unknown"
loading={false}
name={
<React.Fragment>
<mark>
Unkn
</mark>
own CWE
</React.Fragment>
}
placeholder="search.search_for_cwe"
searchable={true}
onClick={[Function]}
tooltip="Unknown CWE"
value="unknown"
/>
</div>
</FacetItemsList>
<MultipleSelectionHint
options={2}
options={0}
values={1}
/>
</React.Fragment>

+ 22
- 18
server/sonar-web/src/main/js/apps/issues/utils.ts View File

@@ -19,7 +19,7 @@
*/
import { searchMembers } from '../../api/organizations';
import { searchUsers } from '../../api/users';
import { Issue } from '../../app/types';
import { Issue, Paging } from '../../app/types';
import { formatMeasure } from '../../helpers/measures';
import { get, save } from '../../helpers/storage';
import {
@@ -201,24 +201,28 @@ export interface ReferencedLanguage {
name: string;
}

export const searchAssignees = (query: string, organization?: string) => {
export interface ReferencedRule {
langName: string;
name: string;
}

export interface SearchedAssignee {
avatar?: string;
login: string;
name: string;
}

export const searchAssignees = (
query: string,
organization: string | undefined,
page = 1
): Promise<{ paging: Paging; results: SearchedAssignee[] }> => {
return organization
? searchMembers({ organization, ps: 50, q: query }).then(response =>
response.users.map(user => ({
avatar: user.avatar,
label: user.name,
value: user.login
}))
)
: searchUsers({ q: query }).then(response =>
response.users.map(user => ({
// TODO this WS returns no avatar
avatar: user.avatar,
email: user.email,
label: user.name,
value: user.login
}))
);
? searchMembers({ organization, p: page, ps: 50, q: query }).then(({ paging, users }) => ({
paging,
results: users
}))
: searchUsers({ p: page, q: query }).then(({ paging, users }) => ({ paging, results: users }));
};

const LOCALSTORAGE_MY = 'my';

+ 0
- 38
server/sonar-web/src/main/js/components/facet/FacetFooter.tsx View File

@@ -1,38 +0,0 @@
/*
* 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 SearchSelect from '../controls/SearchSelect';

type Option = { label: string; value: string };

interface Props {
minimumQueryLength?: number;
onSearch: (query: string) => Promise<Option[]>;
onSelect: (option: Option) => void;
renderOption?: (option: Object) => JSX.Element;
}

export default function FacetFooter(props: Props) {
return (
<div className="search-navigator-facet-footer">
<SearchSelect autofocus={false} {...props} />
</div>
);
}

+ 1
- 1
server/sonar-web/src/main/js/components/facet/FacetHeader.tsx View File

@@ -52,7 +52,7 @@ export default class FacetHeader extends React.PureComponent<Props> {

renderValueIndicator() {
const { values } = this.props;
if (this.props.open || !values || !values.length) {
if (!values || !values.length) {
return null;
}
const value =

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

@@ -57,9 +57,7 @@ export default class FacetItem extends React.PureComponent<Props> {
return this.props.disabled ? (
<span className={className} data-facet={this.props.value}>
<span className="facet-name">{name}</span>
{this.props.stat != null && (
<span className="facet-stat">{this.props.loading ? '' : this.props.stat}</span>
)}
{this.props.stat != null && <span className="facet-stat">{this.props.stat}</span>}
</span>
) : (
<a
@@ -69,9 +67,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.loading ? '' : this.props.stat}</span>
)}
{this.props.stat != null && <span className="facet-stat">{this.props.stat}</span>}
</a>
);
}

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

@@ -0,0 +1,272 @@
/*
* 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 { sortBy, without } from 'lodash';
import FacetBox from './FacetBox';
import FacetHeader from './FacetHeader';
import FacetItem from './FacetItem';
import FacetItemsList from './FacetItemsList';
import MultipleSelectionHint from './MultipleSelectionHint';
import { translate } from '../../helpers/l10n';
import DeferredSpinner from '../common/DeferredSpinner';
import { Paging } from '../../app/types';
import SearchBox from '../controls/SearchBox';
import ListFooter from '../controls/ListFooter';
import { formatMeasure } from '../../helpers/measures';

export interface Props<S> {
facetHeader: string;
fetching: boolean;
getFacetItemText: (item: string) => string;
getSearchResultKey: (result: S) => string;
getSearchResultText: (result: S) => string;
loading?: boolean;
onChange: (changes: { [x: string]: string | string[] }) => void;
onSearch: (query: string, page?: number) => Promise<{ results: S[]; paging: Paging }>;
onToggle: (property: string) => void;
open: boolean;
property: string;
renderFacetItem: (item: string) => React.ReactNode;
renderSearchResult: (result: S, query: string) => React.ReactNode;
searchPlaceholder: string;
values: string[];
stats: { [x: string]: number } | undefined;
}

interface State<S> {
autoFocus: boolean;
query: string;
searching: boolean;
searchResults?: S[];
searchPaging?: Paging;
}

export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> {
mounted = false;

state: State<S> = {
autoFocus: false,
query: '',
searching: false
};

componentDidMount() {
this.mounted = true;
}

componentDidUpdate(prevProps: Props<S>) {
// focus search field *only* if it was manually open
if (!prevProps.open && this.props.open) {
this.setState({ autoFocus: true });
}
}

componentWillUnmount() {
this.mounted = false;
}

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 });
} else {
this.props.onChange({
[this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue]
});
}
};

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

handleClear = () => {
this.props.onChange({ [this.props.property]: [] });
};

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

search = (query: string) => {
if (query.length >= 2) {
this.setState({ query, searching: true });
this.props.onSearch(query).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 });
this.props.onSearch(query, searchPaging.pageIndex + 1).then(({ paging, results }) => {
if (this.mounted) {
this.setState({
searching: false,
searchResults: [...searchResults, ...results],
searchPaging: paging
});
}
}, this.stopSearching);
}
};

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

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

if (!stats) {
return null;
}

const items = sortBy(
Object.keys(stats),
key => -stats[key],
key => this.props.getFacetItemText(key)
);

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

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

return (
<SearchBox
autoFocus={this.state.autoFocus}
className="little-spacer-top spacer-bottom"
loading={this.state.searching}
minLength={2}
onChange={this.search}
placeholder={this.props.searchPlaceholder}
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;
}

return (
<>
<FacetItemsList>
{searchResults.map(result => this.renderSearchResult(result))}
</FacetItemsList>
<ListFooter
count={searchResults.length}
loadMore={this.searchMore}
ready={!searching}
total={searchPaging.total}
/>
</>
);
}

renderSearchResult(result: S) {
const key = this.props.getSearchResultKey(result);
const active = this.props.values.includes(key);
const stat = this.getStat(key);
return (
<FacetItem
active={active}
disabled={!active && stat === 0}
key={key}
loading={this.props.loading}
name={this.props.renderSearchResult(result, this.state.query)}
onClick={this.handleItemClick}
stat={stat && formatFacetStat(stat)}
tooltip={this.props.getSearchResultText(result)}
value={key}
/>
);
}

render() {
const { stats = {} } = this.props;
const values = this.props.values.map(item => this.props.getFacetItemText(item));
return (
<FacetBox property={this.props.property}>
<FacetHeader
name={this.props.facetHeader}
onClear={this.handleClear}
onClick={this.handleHeaderClick}
open={this.props.open}
values={values}
/>

<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={values.length} />
</>
)}
</FacetBox>
);
}
}

function formatFacetStat(stat: number | undefined) {
return stat && formatMeasure(stat, 'SHORT_INT');
}

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

@@ -0,0 +1,144 @@
/*
* 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 ListStyleFacet, { Props } from '../ListStyleFacet';
import { waitAndUpdate } from '../../../helpers/testUtils';

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

it('should select items', () => {
const onChange = jest.fn();
const wrapper = shallowRender({ onChange });
const instance = wrapper.instance() as ListStyleFacet<string>;

// select one item
instance.handleItemClick('b', false);
expect(onChange).lastCalledWith({ foo: ['b'] });
wrapper.setProps({ values: ['b'] });

// select another item
instance.handleItemClick('a', false);
expect(onChange).lastCalledWith({ foo: ['a'] });
wrapper.setProps({ values: ['a'] });

// unselect item
instance.handleItemClick('a', false);
expect(onChange).lastCalledWith({ foo: [] });
wrapper.setProps({ values: [] });

// select multiple items
wrapper.setProps({ values: ['b'] });
instance.handleItemClick('c', true);
expect(onChange).lastCalledWith({ foo: ['b', 'c'] });
wrapper.setProps({ values: ['b', 'c'] });

// unselect item
instance.handleItemClick('c', true);
expect(onChange).lastCalledWith({ foo: ['b'] });
});

it('should toggle', () => {
const onToggle = jest.fn();
const wrapper = shallowRender({ onToggle });
wrapper.find('FacetHeader').prop<Function>('onClick')();
expect(onToggle).toBeCalled();
});

it('should clear', () => {
const onChange = jest.fn();
const wrapper = shallowRender({ onChange, values: ['a'] });
wrapper.find('FacetHeader').prop<Function>('onClear')();
expect(onChange).toBeCalledWith({ foo: [] });
});

it('should search', async () => {
const onSearch = jest.fn().mockResolvedValue({
results: ['d', 'e'],
paging: { pageIndex: 1, pageSize: 2, total: 3 }
});
const wrapper = shallowRender({ onSearch });

// search
wrapper.find('SearchBox').prop<Function>('onChange')('query');
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(onSearch).lastCalledWith('query');

// load more results
onSearch.mockResolvedValue({
results: ['f'],
paging: { pageIndex: 2, pageSize: 2, total: 3 }
});
wrapper.find('ListFooter').prop<Function>('loadMore')();
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(onSearch).lastCalledWith('query', 2);

// clear search
onSearch.mockClear();
wrapper.find('SearchBox').prop<Function>('onChange')('');
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(onSearch).not.toBeCalled();

// search for no results
onSearch.mockResolvedValue({ results: [], paging: { pageIndex: 1, pageSize: 2, total: 0 } });
wrapper.find('SearchBox').prop<Function>('onChange')('blabla');
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
expect(onSearch).lastCalledWith('blabla');

// search fails
onSearch.mockRejectedValue(undefined);
wrapper.find('SearchBox').prop<Function>('onChange')('blabla');
await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot(); // should render previous results
expect(onSearch).lastCalledWith('blabla');
});

function shallowRender(props: Partial<Props<string>> = {}) {
return shallow(
<ListStyleFacet
facetHeader="facet header"
fetching={false}
getFacetItemText={identity}
getSearchResultKey={identity}
getSearchResultText={identity}
onChange={jest.fn()}
onSearch={jest.fn()}
onToggle={jest.fn()}
open={true}
property="foo"
renderFacetItem={identity}
renderSearchResult={identity}
searchPlaceholder="search for foo..."
stats={{ a: 10, b: 8, c: 1 }}
values={[]}
{...props}
/>
);
}

function identity(str: string) {
return str;
}

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

@@ -1,13 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render 1`] = `
<div
className="search-navigator-facet-footer"
>
<SearchSelect
autofocus={false}
onSearch={[MockFunction]}
onSelect={[MockFunction]}
/>
</div>
`;

+ 8
- 1
server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap View File

@@ -112,7 +112,14 @@ exports[`should render open facet with value 1`] = `
</span>
<span
className="search-navigator-facet-header-value spacer-left spacer-right "
/>
>
<span
className="badge badge-secondary is-rounded text-ellipsis"
title="foo"
>
foo
</span>
</span>
</div>
`;


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

@@ -0,0 +1,360 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should render 1`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
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"
/>
</FacetItemsList>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should search 1`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
value="query"
/>
<React.Fragment>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="d"
loading={false}
name="d"
onClick={[Function]}
tooltip="d"
value="d"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="e"
loading={false}
name="e"
onClick={[Function]}
tooltip="e"
value="e"
/>
</FacetItemsList>
<ListFooter
count={2}
loadMore={[Function]}
ready={true}
total={3}
/>
</React.Fragment>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should search 2`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
value="query"
/>
<React.Fragment>
<FacetItemsList>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="d"
loading={false}
name="d"
onClick={[Function]}
tooltip="d"
value="d"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="e"
loading={false}
name="e"
onClick={[Function]}
tooltip="e"
value="e"
/>
<FacetItem
active={false}
disabled={false}
halfWidth={false}
key="f"
loading={false}
name="f"
onClick={[Function]}
tooltip="f"
value="f"
/>
</FacetItemsList>
<ListFooter
count={3}
loadMore={[Function]}
ready={true}
total={3}
/>
</React.Fragment>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should search 3`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
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"
/>
</FacetItemsList>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should search 4`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
value="blabla"
/>
<div
className="note spacer-bottom"
>
no_results
</div>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

exports[`should search 5`] = `
<FacetBox
property="foo"
>
<FacetHeader
name="facet header"
onClear={[Function]}
onClick={[Function]}
open={true}
values={Array []}
/>
<DeferredSpinner
loading={false}
timeout={100}
/>
<React.Fragment>
<SearchBox
autoFocus={false}
className="little-spacer-top spacer-bottom"
loading={false}
minLength={2}
onChange={[Function]}
placeholder="search for foo..."
value="blabla"
/>
<div
className="note spacer-bottom"
>
no_results
</div>
<MultipleSelectionHint
options={3}
values={0}
/>
</React.Fragment>
</FacetBox>
`;

server/sonar-web/src/main/js/components/facet/__tests__/FacetFooter-test.tsx → server/sonar-web/src/main/js/helpers/search.tsx View File

@@ -18,9 +18,16 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import FacetFooter from '../FacetFooter';

it('should render', () => {
expect(shallow(<FacetFooter onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot();
});
export function highlightTerm(str: string, term: string) {
const pos = str.toLowerCase().indexOf(term.toLowerCase());
return pos !== -1 ? (
<>
{pos > 0 && str.substring(0, pos)}
<mark>{str.substr(pos, term.length)}</mark>
{pos + term.length < str.length && str.substring(pos + term.length)}
</>
) : (
str
);
}

Loading…
Cancel
Save