aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main
diff options
context:
space:
mode:
authorStas Vilchik <stas.vilchik@sonarsource.com>2018-08-08 09:17:13 +0200
committerSonarTech <sonartech@sonarsource.com>2018-08-21 20:21:01 +0200
commitd4a017262d4c7e510f9abbfe7f36b3d24b885776 (patch)
tree49a96201018149283724aba4bf6de600ef01fe08 /server/sonar-web/src/main
parent86be78388006563617ae841d577c8bcf98548741 (diff)
downloadsonarqube-d4a017262d4c7e510f9abbfe7f36b3d24b885776.tar.gz
sonarqube-d4a017262d4c7e510f9abbfe7f36b3d24b885776.zip
SONAR-6400 Move the search box above the list of facet items (#592)
Diffstat (limited to 'server/sonar-web/src/main')
-rw-r--r--server/sonar-web/src/main/js/api/components.ts17
-rw-r--r--server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx78
-rw-r--r--server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx63
-rw-r--r--server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx52
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/App.tsx7
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx4
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx181
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx158
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx235
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx153
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx73
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx142
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx10
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap42
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap12
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap131
-rw-r--r--server/sonar-web/src/main/js/apps/issues/utils.ts40
-rw-r--r--server/sonar-web/src/main/js/components/facet/FacetFooter.tsx38
-rw-r--r--server/sonar-web/src/main/js/components/facet/FacetHeader.tsx2
-rw-r--r--server/sonar-web/src/main/js/components/facet/FacetItem.tsx8
-rw-r--r--server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx272
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx144
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetFooter-test.tsx.snap13
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap9
-rw-r--r--server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap360
-rw-r--r--server/sonar-web/src/main/js/helpers/search.tsx (renamed from server/sonar-web/src/main/js/components/facet/__tests__/FacetFooter-test.tsx)17
28 files changed, 1492 insertions, 781 deletions
diff --git a/server/sonar-web/src/main/js/api/components.ts b/server/sonar-web/src/main/js/api/components.ts
index 23b90e37a33..de4f0cb7a4f 100644
--- a/server/sonar-web/src/main/js/api/components.ts
+++ b/server/sonar-web/src/main/js/api/components.ts
@@ -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 });
}
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx
index c883f930557..2157620ad2f 100644
--- a/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacet.tsx
@@ -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);
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx
deleted file mode 100644
index 97d247d1363..00000000000
--- a/server/sonar-web/src/main/js/apps/coding-rules/components/LanguageFacetFooter.tsx
+++ /dev/null
@@ -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>
- );
- }
-}
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx
index c403ff97449..be3049145fa 100644
--- a/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/coding-rules/components/TagFacet.tsx
@@ -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}
/>
);
}
diff --git a/server/sonar-web/src/main/js/apps/issues/components/App.tsx b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
index 446956be3eb..83f5e3d1fe0 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/App.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/App.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
index 1a2859fa7f6..43c03a88fdb 100644
--- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
@@ -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) => {
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
index 6c1b8275cff..d7b83bf6228 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
@@ -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} />
</>
)}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
index 0be9e8b7f00..65f2144844e 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/LanguageFacet.tsx
@@ -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);
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
index 823e453bf2a..24f4915fb09 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ProjectFacet.tsx
@@ -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>
- );
- }
}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
index e2ec1cdc721..be0c32bc774 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/RuleFacet.tsx
@@ -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}
+ />
);
}
}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
index 2953934dd4a..9b6c3217a94 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
@@ -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 };
}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
index c5582c3fc1b..e3cb518d74a 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/StandardFacet.tsx
@@ -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()}
</>
)}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
index 9ebf4422d95..75d251a4dfe 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/TagFacet.tsx
@@ -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}
+ />
);
}
}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
index 64217b4de0b..0d797101882 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/AssigneeFacet-test.tsx
@@ -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'] });
-});
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx
index 815d6b03f93..41d59a42a92 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/StandardFacet-test.tsx
@@ -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> = {}) {
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
index ed02501141f..1ab0e42e2a6 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/AssigneeFacet-test.tsx.snap
@@ -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}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap
index db112435946..bc2e66a808c 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/Sidebar-test.tsx.snap
@@ -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",
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap
index 4d59f3a0b4d..939fb386fd6 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/__snapshots__/StandardFacet-test.tsx.snap
@@ -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>
diff --git a/server/sonar-web/src/main/js/apps/issues/utils.ts b/server/sonar-web/src/main/js/apps/issues/utils.ts
index fbbbf4dafae..e6449228325 100644
--- a/server/sonar-web/src/main/js/apps/issues/utils.ts
+++ b/server/sonar-web/src/main/js/apps/issues/utils.ts
@@ -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';
diff --git a/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx b/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx
deleted file mode 100644
index 8879f11264f..00000000000
--- a/server/sonar-web/src/main/js/components/facet/FacetFooter.tsx
+++ /dev/null
@@ -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>
- );
-}
diff --git a/server/sonar-web/src/main/js/components/facet/FacetHeader.tsx b/server/sonar-web/src/main/js/components/facet/FacetHeader.tsx
index 0c496278381..1b4fc0e4957 100644
--- a/server/sonar-web/src/main/js/components/facet/FacetHeader.tsx
+++ b/server/sonar-web/src/main/js/components/facet/FacetHeader.tsx
@@ -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 =
diff --git a/server/sonar-web/src/main/js/components/facet/FacetItem.tsx b/server/sonar-web/src/main/js/components/facet/FacetItem.tsx
index e2867c5d9da..0f86901fffb 100644
--- a/server/sonar-web/src/main/js/components/facet/FacetItem.tsx
+++ b/server/sonar-web/src/main/js/components/facet/FacetItem.tsx
@@ -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>
);
}
diff --git a/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx
new file mode 100644
index 00000000000..ae2222ea4cb
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx
@@ -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');
+}
diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx b/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx
new file mode 100644
index 00000000000..7cb3f16a0a0
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/facet/__tests__/ListStyleFacet-test.tsx
@@ -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;
+}
diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetFooter-test.tsx.snap b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetFooter-test.tsx.snap
deleted file mode 100644
index 9043a2cc22c..00000000000
--- a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetFooter-test.tsx.snap
+++ /dev/null
@@ -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>
-`;
diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap
index 4dba5b7b374..27ff73ed3fa 100644
--- a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap
+++ b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/FacetHeader-test.tsx.snap
@@ -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>
`;
diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
new file mode 100644
index 00000000000..7b05d4a28f0
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/facet/__tests__/__snapshots__/ListStyleFacet-test.tsx.snap
@@ -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>
+`;
diff --git a/server/sonar-web/src/main/js/components/facet/__tests__/FacetFooter-test.tsx b/server/sonar-web/src/main/js/helpers/search.tsx
index ce1f6f4f43c..2c516d10cf6 100644
--- a/server/sonar-web/src/main/js/components/facet/__tests__/FacetFooter-test.tsx
+++ b/server/sonar-web/src/main/js/helpers/search.tsx
@@ -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
+ );
+}