@@ -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 }); | |||
} | |||
@@ -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); |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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; |
@@ -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) => { |
@@ -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} /> | |||
</> | |||
)} |
@@ -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); |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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 }; | |||
} | |||
@@ -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()} | |||
</> | |||
)} |
@@ -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} | |||
/> | |||
); | |||
} | |||
} |
@@ -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'] }); | |||
}); |
@@ -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> = {}) { |
@@ -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} |
@@ -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", |
@@ -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> |
@@ -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'; |
@@ -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> | |||
); | |||
} |
@@ -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 = |
@@ -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> | |||
); | |||
} |
@@ -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'); | |||
} |
@@ -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; | |||
} |
@@ -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> | |||
`; |
@@ -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> | |||
`; | |||
@@ -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> | |||
`; |
@@ -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 | |||
); | |||
} |