diff options
author | Stas Vilchik <stas.vilchik@sonarsource.com> | 2018-08-08 09:17:13 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-08-21 20:21:01 +0200 |
commit | d4a017262d4c7e510f9abbfe7f36b3d24b885776 (patch) | |
tree | 49a96201018149283724aba4bf6de600ef01fe08 /server/sonar-web/src/main | |
parent | 86be78388006563617ae841d577c8bcf98548741 (diff) | |
download | sonarqube-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')
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 + ); +} |