From 3aa961cd6a7d63edb7ac011c27ec695dfb94c253 Mon Sep 17 00:00:00 2001 From: stanislavh Date: Fri, 21 Apr 2023 14:56:17 +0200 Subject: [PATCH] SONAR-19069 Add Show more filters button --- .../coding-rules/components/FacetsList.tsx | 1 + .../__snapshots__/FacetsList-test.tsx.snap | 1 + .../js/apps/issues/__tests__/IssuesApp-it.tsx | 8 ++ .../js/apps/issues/components/IssuesApp.tsx | 24 ++++- .../js/apps/issues/sidebar/AssigneeFacet.tsx | 18 ++-- .../js/apps/issues/sidebar/AuthorFacet.tsx | 17 +++- .../issues/sidebar/CharacteristicFacet.tsx | 4 +- .../apps/issues/sidebar/CreationDateFacet.tsx | 12 ++- .../js/apps/issues/sidebar/DirectoryFacet.tsx | 22 +++-- .../main/js/apps/issues/sidebar/FileFacet.tsx | 22 +++-- .../sidebar/FiltersVisibilityButton.tsx | 44 +++++++++ .../js/apps/issues/sidebar/LanguageFacet.tsx | 17 +++- .../js/apps/issues/sidebar/ProjectFacet.tsx | 22 +++-- .../apps/issues/sidebar/ResolutionFacet.tsx | 11 ++- .../main/js/apps/issues/sidebar/RuleFacet.tsx | 7 +- .../js/apps/issues/sidebar/ScopeFacet.tsx | 6 +- .../main/js/apps/issues/sidebar/Sidebar.tsx | 95 +++++++++++++------ .../js/apps/issues/sidebar/StandardFacet.tsx | 70 ++++++++------ .../js/apps/issues/sidebar/StatusFacet.tsx | 10 +- .../main/js/apps/issues/sidebar/TagFacet.tsx | 17 +++- .../main/js/apps/issues/sidebar/TypeFacet.tsx | 7 +- .../issues/sidebar/__tests__/Sidebar-it.tsx | 51 ++++++++++ .../src/main/js/apps/issues/test-utils.tsx | 2 + .../resources/org/sonar/l10n/core.properties | 2 + 24 files changed, 370 insertions(+), 120 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/FiltersVisibilityButton.tsx diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx index a9b52ce93e8..1bb599037e1 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/FacetsList.tsx @@ -125,6 +125,7 @@ export default function FacetsList(props: FacetsListProps) { sonarsourceSecurity={props.query.sonarsourceSecurity} sonarsourceSecurityOpen={!!props.openFacets.sonarsourceSecurity} sonarsourceSecurityStats={props.facets && props.facets.sonarsourceSecurity} + forceShow={true} /> { it('should support OWASP Top 10 version 2021', async () => { const user = userEvent.setup(); renderIssueApp(); + await user.click(ui.showFiltersButton().get()); await user.click(screen.getByRole('button', { name: 'issues.facet.standards' })); const owaspTop102021 = screen.getByRole('button', { name: 'issues.facet.owaspTop10_2021' }); expect(owaspTop102021).toBeInTheDocument(); @@ -272,6 +273,7 @@ describe('issues app', () => { const user = userEvent.setup(); renderIssueApp(); await waitOnDataLoaded(); + await user.click(ui.showFiltersButton().get()); // Ensure issue type filter is unchecked await user.click(ui.typeFacet.get()); @@ -327,6 +329,7 @@ describe('issues app', () => { const user = userEvent.setup(); renderIssueApp(); await waitOnDataLoaded(); + await user.click(ui.showFiltersButton().get()); // Select a characteristic await user.click(ui.clearCharacteristicFilter.get()); @@ -435,6 +438,7 @@ describe('issues app', () => { issuesHandler.setCurrentUser(currentUser); renderIssueApp(currentUser); await waitOnDataLoaded(); + await user.click(ui.showFiltersButton().get()); // Select a specific date range such that only one issue matches await user.click(ui.creationDateFacet.get()); @@ -480,6 +484,7 @@ describe('issues app', () => { renderIssueApp(); + await user.click(ui.showFiltersButton().get()); await user.click(await ui.ruleFacet.find()); await user.type(ui.ruleFacetSearch.get(), 'rule'); expect(within(ui.ruleFacetList.get()).getAllByRole('checkbox')).toHaveLength(2); @@ -494,6 +499,7 @@ describe('issues app', () => { }) ).toBeInTheDocument(); + await user.click(await ui.typeFacet.find()); await user.click(ui.vulnerabilityIssueTypeFilter.get()); // after changing the issue type filter, search field is reset, so we type again await user.type(ui.ruleFacetSearch.get(), 'rule'); @@ -515,6 +521,7 @@ describe('issues app', () => { renderIssueApp(); + await user.click(ui.showFiltersButton().get()); await user.click(await ui.languageFacet.find()); expect(await ui.languageFacetList.find()).toBeInTheDocument(); expect( @@ -526,6 +533,7 @@ describe('issues app', () => { await user.click(ui.languageFacet.get()); expect(ui.languageFacetList.query()).not.toBeInTheDocument(); + await user.click(await ui.typeFacet.find()); await user.click(ui.vulnerabilityIssueTypeFilter.get()); await user.click(ui.languageFacet.get()); expect(await ui.languageFacetList.find()).toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index dc3e038eb77..4b482da7fea 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -33,11 +33,11 @@ import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; import EmptySearch from '../../../components/common/EmptySearch'; import FiltersHeader from '../../../components/common/FiltersHeader'; import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; +import { Button } from '../../../components/controls/buttons'; import ButtonToggle from '../../../components/controls/ButtonToggle'; import Checkbox from '../../../components/controls/Checkbox'; import HelpTooltip from '../../../components/controls/HelpTooltip'; import ListFooter from '../../../components/controls/ListFooter'; -import { Button } from '../../../components/controls/buttons'; import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import withIndexationGuard from '../../../components/hoc/withIndexationGuard'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; @@ -80,23 +80,24 @@ import { CurrentUser, UserBase } from '../../../types/users'; import * as actions from '../actions'; import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList'; import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeader'; +import FiltersVisibilityButton from '../sidebar/FiltersVisibilityButton'; import Sidebar from '../sidebar/Sidebar'; import '../styles.css'; import { - OpenFacets, - Query, - STANDARDS, areMyIssuesSelected, areQueriesEqual, getOpen, getOpenIssue, + OpenFacets, parseFacets, parseQuery, + Query, saveMyIssues, serializeQuery, shouldOpenSonarSourceSecurityFacet, shouldOpenStandardsChildFacet, shouldOpenStandardsFacet, + STANDARDS, } from '../utils'; import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal'; import IssueHeader from './IssueHeader'; @@ -129,6 +130,7 @@ export interface State { loadingMore: boolean; locationsNavigator: boolean; myIssues: boolean; + showAllFilters: boolean; openFacets: OpenFacets; openIssue?: Issue; openPopup?: { issue: string; name: string }; @@ -168,6 +170,7 @@ export class App extends React.PureComponent { loadingMore: false, locationsNavigator: false, myIssues: areMyIssuesSelected(props.location.query), + showAllFilters: false, openFacets: { characteristics: { [IssueCharacteristicFitFor.Production]: true, @@ -644,6 +647,12 @@ export class App extends React.PureComponent { return translateWithParameters('issues.bulk_change_X_issues', count); }; + handleShowFiltersChange = (showAllFilters: boolean) => { + this.setState({ + showAllFilters, + }); + }; + handleFilterChange = (changes: Partial) => { this.props.router.push({ pathname: this.props.location.pathname, @@ -905,7 +914,7 @@ export class App extends React.PureComponent { renderFacets() { const { component, currentUser, branchLike } = this.props; - const { query } = this.state; + const { query, showAllFilters } = this.state; return (
@@ -934,12 +943,17 @@ export class App extends React.PureComponent { onFilterChange={this.handleFilterChange} openFacets={this.state.openFacets} query={query} + showAllFilters={showAllFilters} referencedComponentsById={this.state.referencedComponentsById} referencedComponentsByKey={this.state.referencedComponentsByKey} referencedLanguages={this.state.referencedLanguages} referencedRules={this.state.referencedRules} referencedUsers={this.state.referencedUsers} /> +
); } 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 89d3d4f6f31..ff8ff15a035 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 @@ -39,6 +39,7 @@ interface Props { query: Query; stats: Dict | undefined; referencedUsers: Dict; + forceShow: boolean; } export default class AssigneeFacet extends React.PureComponent { @@ -137,15 +138,20 @@ export default class AssigneeFacet extends React.PureComponent { }; render() { - const values = [...this.props.assignees]; - if (!this.props.assigned) { + const { forceShow, assignees, assigned, stats, open, fetching, query } = this.props; + const values = [...assignees]; + if (!assigned) { values.push(''); } + if (values.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.assignees')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getAssigneeName} getSearchResultKey={(user) => user.login} getSearchResultText={(user) => user.name || user.login} @@ -157,13 +163,13 @@ export default class AssigneeFacet extends React.PureComponent { onItemClick={this.handleItemClick} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} + open={open} property="assignees" - query={omit(this.props.query, 'assigned', 'assignees')} + query={omit(query, 'assigned', 'assignees')} renderFacetItem={this.renderFacetItem} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_users')} - stats={this.props.stats} + stats={stats} values={values} /> ); diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx index 1647c689495..ada0eb6b2d7 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/AuthorFacet.tsx @@ -37,6 +37,7 @@ interface Props { query: Query; stats: Dict | undefined; author: string[]; + forceShow: boolean; } const SEARCH_SIZE = 100; @@ -66,10 +67,16 @@ export default class AuthorFacet extends React.PureComponent { }; render() { + const { forceShow, fetching, open, stats, author, query } = this.props; + + if (author.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.authors')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.identity} getSearchResultKey={this.identity} getSearchResultText={this.identity} @@ -77,14 +84,14 @@ export default class AuthorFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} + open={open} property="author" - query={omit(this.props.query, 'author')} + query={omit(query, 'author')} renderFacetItem={this.identity} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_authors')} - stats={this.props.stats} - values={this.props.author} + stats={stats} + values={author} /> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx index 094f3e27c72..eb0b153027e 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx @@ -154,7 +154,9 @@ export default class CharacteristicFacet extends React.PureComponent { {this.props.open && ( <> - {availableCharacteristics.map(this.renderItem)} + + {availableCharacteristics.map(this.renderItem)} + | undefined; + forceShow: boolean; } export class CreationDateFacet extends React.PureComponent { @@ -259,10 +260,10 @@ export class CreationDateFacet extends React.PureComponent - +
- + ); @@ -287,7 +288,12 @@ export class CreationDateFacet extends React.PureComponent diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx index b4888017813..00b1ae90115 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/DirectoryFacet.tsx @@ -29,6 +29,7 @@ import { highlightTerm } from '../../../helpers/search'; import { BranchLike } from '../../../types/branch-like'; import { TreeComponentWithPath } from '../../../types/component'; import { Facet } from '../../../types/issues'; +import { MetricKey } from '../../../types/metrics'; import { Query } from '../utils'; interface Props { @@ -42,6 +43,7 @@ interface Props { open: boolean; query: Query; stats: Facet | undefined; + forceShow: boolean; } export default class DirectoryFacet extends React.PureComponent { @@ -73,7 +75,7 @@ export default class DirectoryFacet extends React.PureComponent { }; loadSearchResultCount = (directories: TreeComponentWithPath[]) => { - return this.props.loadSearchResultCount('directories', { + return this.props.loadSearchResultCount(MetricKey.directories, { directories: directories.map((directory) => directory.path), }); }; @@ -94,10 +96,16 @@ export default class DirectoryFacet extends React.PureComponent { }; render() { + const { forceShow, directories, stats, fetching, open, query } = this.props; + + if (directories.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.directories')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getFacetItemText} getSearchResultKey={this.getSearchResultKey} getSearchResultText={this.getSearchResultText} @@ -106,14 +114,14 @@ export default class DirectoryFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} - property="directories" - query={omit(this.props.query, 'directories')} + open={open} + property={MetricKey.directories} + query={omit(query, MetricKey.directories)} renderFacetItem={this.renderFacetItem} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_directories')} - stats={this.props.stats} - values={this.props.directories} + stats={stats} + values={directories} /> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx index e315f8e0454..e27b23a5104 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FileFacet.tsx @@ -30,6 +30,7 @@ import { isDefined } from '../../../helpers/types'; import { BranchLike } from '../../../types/branch-like'; import { TreeComponentWithPath } from '../../../types/component'; import { Facet } from '../../../types/issues'; +import { MetricKey } from '../../../types/metrics'; import { Query } from '../utils'; interface Props { @@ -43,6 +44,7 @@ interface Props { open: boolean; query: Query; stats: Facet | undefined; + forceShow: boolean; } const MAX_PATH_LENGTH = 15; @@ -75,7 +77,7 @@ export default class FileFacet extends React.PureComponent { }; loadSearchResultCount = (files: TreeComponentWithPath[]) => { - return this.props.loadSearchResultCount('files', { + return this.props.loadSearchResultCount(MetricKey.files, { files: files .map((file) => { return file.path; @@ -106,10 +108,16 @@ export default class FileFacet extends React.PureComponent { }; render() { + const { forceShow, files, fetching, open, query, stats } = this.props; + + if (files.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.files')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getFacetItemText} getSearchResultKey={this.getSearchResultKey} getSearchResultText={this.getSearchResultText} @@ -118,14 +126,14 @@ export default class FileFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} - property="files" - query={omit(this.props.query, 'files')} + open={open} + property={MetricKey.files} + query={omit(query, MetricKey.files)} renderFacetItem={this.renderFacetItem} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_files')} - stats={this.props.stats} - values={this.props.files} + stats={stats} + values={files} /> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersVisibilityButton.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersVisibilityButton.tsx new file mode 100644 index 00000000000..45da86c9112 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/FiltersVisibilityButton.tsx @@ -0,0 +1,44 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 classNames from 'classnames'; +import React from 'react'; +import { Button } from '../../../components/controls/buttons'; +import { translate } from '../../../helpers/l10n'; + +interface Props { + showAllFilters: boolean; + onClick: (val: boolean) => void; +} + +export default function FiltersVisibilityButton(props: Props) { + const { showAllFilters } = props; + + return ( +
+ +
+ ); +} 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 992f4a63b5c..a2b34e96573 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 @@ -39,6 +39,7 @@ interface Props { query: Query; referencedLanguages: Dict; stats: Dict | undefined; + forceShow: boolean; } class LanguageFacet extends React.PureComponent { @@ -79,10 +80,16 @@ class LanguageFacet extends React.PureComponent { }; render() { + const { forceShow, stats, selectedLanguages, open, fetching, query } = this.props; + + if (selectedLanguages.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.languages')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getLanguageName} getSearchResultKey={(language) => language.key} getSearchResultText={(language) => language.name} @@ -91,14 +98,14 @@ class LanguageFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} + open={open} property="languages" - query={omit(this.props.query, 'languages')} + query={omit(query, 'languages')} renderFacetItem={this.getLanguageName} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_languages')} - stats={this.props.stats} - values={this.props.selectedLanguages} + stats={stats} + values={selectedLanguages} /> ); } 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 4c738b2f4fa..93ebce99746 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 @@ -26,6 +26,7 @@ import { translate } from '../../../helpers/l10n'; import { highlightTerm } from '../../../helpers/search'; import { ComponentQualifier } from '../../../types/component'; import { Facet, ReferencedComponent } from '../../../types/issues'; +import { MetricKey } from '../../../types/metrics'; import { Component, Dict, Paging } from '../../../types/types'; import { Query } from '../utils'; @@ -40,6 +41,7 @@ interface Props { query: Query; referencedComponents: Dict; stats: Dict | undefined; + forceShow: boolean; } interface SearchedProject { @@ -95,7 +97,7 @@ export default class ProjectFacet extends React.PureComponent { }; loadSearchResultCount = (projects: SearchedProject[]) => { - return this.props.loadSearchResultCount('projects', { + return this.props.loadSearchResultCount(MetricKey.projects, { projects: projects.map((project) => project.key), }); }; @@ -117,10 +119,16 @@ export default class ProjectFacet extends React.PureComponent { ); render() { + const { forceShow, projects, stats, open, fetching, query } = this.props; + + if (projects.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.projects')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getProjectName} getSearchResultKey={(project) => project.key} getSearchResultText={(project) => project.name} @@ -128,14 +136,14 @@ export default class ProjectFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} - property="projects" - query={omit(this.props.query, 'projects')} + open={open} + property={MetricKey.projects} + query={omit(query, MetricKey.projects)} renderFacetItem={this.renderFacetItem} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_projects')} - stats={this.props.stats} - values={this.props.projects} + stats={stats} + values={projects} /> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx index 32578f5dc75..c437f10efc4 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ResolutionFacet.tsx @@ -37,6 +37,7 @@ interface Props { resolved: boolean; resolutions: string[]; stats: Dict | undefined; + forceShow: boolean; } const RESOLUTIONS = [ @@ -55,10 +56,10 @@ export default class ResolutionFacet extends React.PureComponent { }; handleItemClick = (itemValue: string, multiple: boolean) => { - const { resolutions } = this.props; + const { resolutions, resolved } = this.props; if (itemValue === '') { // unresolved - this.props.onChange({ resolved: !this.props.resolved, resolutions: [] }); + this.props.onChange({ resolved: !resolved, resolutions: [] }); } else if (multiple) { const newValue = orderBy( resolutions.includes(itemValue) @@ -115,9 +116,13 @@ export default class ResolutionFacet extends React.PureComponent { }; render() { - const { fetching, open, resolutions, stats = {} } = this.props; + const { resolutions, stats = {}, forceShow, fetching, open } = this.props; const values = resolutions.map((resolution) => this.getFacetItemName(resolution)); + if (values.length < 1 && !forceShow) { + return null; + } + return ( ; stats: Dict | undefined; + forceShow: boolean; } export default class RuleFacet extends React.PureComponent { @@ -78,7 +79,11 @@ export default class RuleFacet extends React.PureComponent { }; render() { - const { fetching, open, query, stats } = this.props; + const { forceShow, stats, query, open, fetching } = this.props; + + if (query.rules.length < 1 && !forceShow) { + return null; + } return ( diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx index 329c046b92d..73320c77762 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ScopeFacet.tsx @@ -37,13 +37,17 @@ export interface ScopeFacetProps { open: boolean; scopes: string[]; stats: Dict | undefined; + forceShow: boolean; } export default function ScopeFacet(props: ScopeFacetProps) { - const { fetching, open, scopes = [], stats = {} } = props; + const { fetching, open, scopes = [], stats = {}, forceShow } = props; const values = scopes.map((scope) => translate('issue.scope', scope)); const property = 'scopes'; + if (values.length < 1 && !forceShow) { + return null; + } return ( 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 8f25db0785b..c677090a724 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 @@ -76,11 +76,21 @@ export interface Props { referencedLanguages: Dict; referencedRules: Dict; referencedUsers: Dict; + showAllFilters: boolean; } export class Sidebar extends React.PureComponent { renderComponentFacets() { - const { component, facets, loadingFacets, openFacets, query, branchLike } = this.props; + const { + component, + facets, + loadingFacets, + openFacets, + query, + branchLike, + showAllFilters, + loadSearchResultCount, + } = this.props; const hasFileOrDirectory = !isApplication(component?.qualifier) && !isPortfolioLike(component?.qualifier); if (!component || !hasFileOrDirectory) { @@ -88,7 +98,7 @@ export class Sidebar extends React.PureComponent { } const commonProps = { componentKey: component.key, - loadSearchResultCount: this.props.loadSearchResultCount, + loadSearchResultCount, onChange: this.props.onFilterChange, onToggle: this.props.onFacetToggle, query, @@ -102,6 +112,7 @@ export class Sidebar extends React.PureComponent { fetching={loadingFacets.directories === true} open={!!openFacets.directories} stats={facets.directories} + forceShow={showAllFilters} {...commonProps} /> )} @@ -111,6 +122,7 @@ export class Sidebar extends React.PureComponent { files={query.files} open={!!openFacets.files} stats={facets.files} + forceShow={showAllFilters} {...commonProps} /> @@ -126,6 +138,13 @@ export class Sidebar extends React.PureComponent { openFacets, query, branchLike, + showAllFilters, + loadingFacets, + loadSearchResultCount, + referencedRules, + referencedLanguages, + referencedComponentsByKey, + referencedUsers, } = this.props; const disableDeveloperAggregatedInfo = @@ -144,14 +163,14 @@ export class Sidebar extends React.PureComponent { <> {displayPeriodFilter && ( )} { characteristics={query.characteristics as IssueCharacteristic[]} /> { stats={facets.severities} /> + + { sonarsourceSecurity={query.sonarsourceSecurity} sonarsourceSecurityOpen={!!openFacets.sonarsourceSecurity} sonarsourceSecurityStats={facets.sonarsourceSecurity} + forceShow={showAllFilters} /> { createdAt={query.createdAt} createdBefore={query.createdBefore} createdInLast={query.createdInLast} - fetching={this.props.loadingFacets.createdAt === true} + fetching={loadingFacets.createdAt === true} onChange={this.props.onFilterChange} onToggle={this.props.onFacetToggle} open={!!openFacets.createdAt} inNewCodePeriod={query.inNewCodePeriod} stats={facets.createdAt} + forceShow={showAllFilters} /> {displayProjectsFacet && ( )} {this.renderComponentFacets()} @@ -298,27 +329,29 @@ export class Sidebar extends React.PureComponent { )} {displayAuthorFacet && !disableDeveloperAggregatedInfo && ( )} 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 acc5443f5a0..c5b9c80cdf0 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 @@ -64,6 +64,7 @@ interface Props { sonarsourceSecurity: string[]; sonarsourceSecurityOpen: boolean; sonarsourceSecurityStats: Dict | undefined; + forceShow: boolean; } interface State { @@ -99,19 +100,13 @@ export default class StandardFacet extends React.PureComponent { this.mounted = true; // load standards.json only if the facet is open, or there is a selected value - if ( - this.props.open || - this.props.owaspTop10.length > 0 || - this.props['owaspTop10-2021'].length > 0 || - this.props.cwe.length > 0 || - this.props.sonarsourceSecurity.length > 0 - ) { + if (this.isFacetVisible() || this.props.open) { this.loadStandards(); } } componentDidUpdate(prevProps: Props) { - if (!prevProps.open && this.props.open) { + if (!prevProps.open && this.props.open && this.isFacetVisible()) { this.loadStandards(); } } @@ -150,17 +145,15 @@ export default class StandardFacet extends React.PureComponent { }; getValues = () => { + const { sonarsourceSecurity, owaspTop10, 'owaspTop10-2021': owaspTop2021, cwe } = this.props; + const { standards } = this.state; return [ - ...this.props.sonarsourceSecurity.map((item) => - renderSonarSourceSecurityCategory(this.state.standards, item, true) + ...sonarsourceSecurity.map((item) => + renderSonarSourceSecurityCategory(standards, item, true) ), - ...this.props.owaspTop10.map((item) => - renderOwaspTop10Category(this.state.standards, item, true) - ), - ...this.props['owaspTop10-2021'].map((item) => - renderOwaspTop102021Category(this.state.standards, item, true) - ), - ...this.props.cwe.map((item) => renderCWECategory(this.state.standards, item)), + ...owaspTop10.map((item) => renderOwaspTop10Category(standards, item, true)), + ...owaspTop2021.map((item) => renderOwaspTop102021Category(standards, item, true)), + ...cwe.map((item) => renderCWECategory(standards, item)), ]; }; @@ -231,6 +224,19 @@ export default class StandardFacet extends React.PureComponent { : Promise.resolve({}); }; + isFacetVisible = () => { + const { + forceShow, + cwe, + sonarsourceSecurity, + owaspTop10, + 'owaspTop10-2021': owaspTop2021, + } = this.props; + const values = [...cwe, ...sonarsourceSecurity, ...owaspTop10, ...owaspTop2021]; + + return !(values.length < 1 && !forceShow); + }; + renderList = ( statsProp: StatsProp, valuesProp: ValuesProp, @@ -318,8 +324,8 @@ export default class StandardFacet extends React.PureComponent { } renderSonarSourceSecurityList() { - const stats = this.props.sonarsourceSecurityStats; - const values = this.props.sonarsourceSecurity; + const { sonarsourceSecurityStats: stats, sonarsourceSecurity: values } = this.props; + const { standards, showFullSonarSourceList } = this.state; if (!stats) { return null; @@ -328,15 +334,15 @@ export default class StandardFacet extends React.PureComponent { const sortedItems = sortBy( Object.keys(stats), (key) => -stats[key], - (key) => renderSonarSourceSecurityCategory(this.state.standards, key) + (key) => renderSonarSourceSecurityCategory(standards, key) ); - const limitedList = this.state.showFullSonarSourceList + const limitedList = showFullSonarSourceList ? sortedItems : sortedItems.slice(0, INITIAL_FACET_COUNT); // make sure all selected items are displayed - const selectedBelowLimit = this.state.showFullSonarSourceList + const selectedBelowLimit = showFullSonarSourceList ? [] : sortedItems.slice(INITIAL_FACET_COUNT).filter((item) => values.includes(item)); @@ -415,6 +421,8 @@ export default class StandardFacet extends React.PureComponent { sonarsourceSecurity, sonarsourceSecurityOpen, } = this.props; + const { standards } = this.state; + return ( <> @@ -424,7 +432,7 @@ export default class StandardFacet extends React.PureComponent { onClick={this.handleSonarSourceSecurityHeaderClick} open={sonarsourceSecurityOpen} values={sonarsourceSecurity.map((item) => - renderSonarSourceSecurityCategory(this.state.standards, item) + renderSonarSourceSecurityCategory(standards, item) )} /> {sonarsourceSecurityOpen && ( @@ -440,9 +448,7 @@ export default class StandardFacet extends React.PureComponent { name={translate('issues.facet.owaspTop10_2021')} onClick={this.handleOwaspTop102021HeaderClick} open={owaspTop102021Open} - values={owaspTop102021.map((item) => - renderOwaspTop102021Category(this.state.standards, item) - )} + values={owaspTop102021.map((item) => renderOwaspTop102021Category(standards, item))} /> {owaspTop102021Open && ( <> @@ -470,9 +476,9 @@ export default class StandardFacet extends React.PureComponent { className="is-inner" facetHeader={translate('issues.facet.cwe')} fetching={fetchingCwe} - getFacetItemText={(item) => renderCWECategory(this.state.standards, item)} + getFacetItemText={(item) => renderCWECategory(standards, item)} getSearchResultKey={(item) => item} - getSearchResultText={(item) => renderCWECategory(this.state.standards, item)} + getSearchResultText={(item) => renderCWECategory(standards, item)} loadSearchResultCount={this.loadCWESearchResultCount} onChange={this.props.onChange} onSearch={this.handleCWESearch} @@ -480,9 +486,9 @@ export default class StandardFacet extends React.PureComponent { open={cweOpen} property={SecurityStandard.CWE} query={omit(query, 'cwe')} - renderFacetItem={(item) => renderCWECategory(this.state.standards, item)} + renderFacetItem={(item) => renderCWECategory(standards, item)} renderSearchResult={(item, query) => - highlightTerm(renderCWECategory(this.state.standards, item), query) + highlightTerm(renderCWECategory(standards, item), query) } searchPlaceholder={translate('search.search_for_cwe')} stats={cweStats} @@ -495,6 +501,10 @@ export default class StandardFacet extends React.PureComponent { render() { const { open } = this.props; + if (!this.isFacetVisible()) { + return null; + } + return ( | undefined; statuses: string[]; + forceShow: boolean; } const STATUSES = ['OPEN', 'CONFIRMED', 'REOPENED', 'RESOLVED', 'CLOSED']; @@ -73,7 +74,8 @@ export default class StatusFacet extends React.PureComponent { } renderItem = (status: string) => { - const active = this.props.statuses.includes(status); + const { statuses } = this.props; + const active = statuses.includes(status); const stat = this.getStat(status); return ( @@ -91,9 +93,13 @@ export default class StatusFacet extends React.PureComponent { }; render() { - const { fetching, open, statuses, stats = {} } = this.props; + const { statuses, stats = {}, forceShow, fetching, open } = this.props; const values = statuses.map((status) => translate('issue.status', status)); + if (values.length < 1 && !forceShow) { + return null; + } + return ( | undefined; tags: string[]; + forceShow: boolean; } const SEARCH_SIZE = 100; @@ -82,10 +83,16 @@ export default class TagFacet extends React.PureComponent { ); render() { + const { forceShow, tags, fetching, stats, open, query } = this.props; + + if (tags.length < 1 && !forceShow) { + return null; + } + return ( facetHeader={translate('issues.facet.tags')} - fetching={this.props.fetching} + fetching={fetching} getFacetItemText={this.getTagName} getSearchResultKey={(tag) => tag} getSearchResultText={(tag) => tag} @@ -93,14 +100,14 @@ export default class TagFacet extends React.PureComponent { onChange={this.props.onChange} onSearch={this.handleSearch} onToggle={this.props.onToggle} - open={this.props.open} + open={open} property="tags" - query={omit(this.props.query, 'tags')} + query={omit(query, 'tags')} renderFacetItem={this.renderTag} renderSearchResult={this.renderSearchResult} searchPlaceholder={translate('search.search_for_tags')} - stats={this.props.stats} - values={this.props.tags} + stats={stats} + values={tags} /> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx index 28b1a8f8668..51ea8f58f4b 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/TypeFacet.tsx @@ -37,6 +37,7 @@ interface Props { open: boolean; stats: Dict | undefined; types: string[]; + forceShow: boolean; } export default class TypeFacet extends React.PureComponent { @@ -99,9 +100,13 @@ export default class TypeFacet extends React.PureComponent { }; render() { - const { fetching, open, types, stats = {} } = this.props; + const { types, stats = {}, forceShow, open, fetching } = this.props; const values = types.map((type) => translate('issue.type', type)); + if (values.length < 1 && !forceShow) { + return null; + } + return ( { ]); }); +it('should render only main visible facets: Characteristics & Severity', () => { + renderSidebar({ + component: mockComponent(), + showAllFilters: false, + query: mockQuery({ assigned: true }), + }); + + expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([ + 'issues.facet.characteristics.PRODUCTION', + 'issues.facet.characteristics.DEVELOPMENT', + 'issues.facet.severities', + ]); +}); + +it('should render secondary facets with filters applied eventhough "Show more filters" button isn`t toggled', () => { + renderSidebar({ + component: mockComponent(), + showAllFilters: false, + query: mockQuery({ + assigned: false, + tags: ['tag'], + rules: ['rule'], + directories: ['directory'], + cwe: ['security'], + languages: ['java'], + severities: [IssueSeverity.Blocker], + }), + }); + + expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([ + 'issues.facet.characteristics.PRODUCTION', + 'issues.facet.characteristics.DEVELOPMENT', + 'issues.facet.severities', + 'clear', + 'issues.facet.standards', + 'clear', + 'issues.facet.languages', + 'clear', + 'issues.facet.rules', + 'clear', + 'issues.facet.tags', + 'clear', + 'issues.facet.directories', + 'clear', + 'issues.facet.assignees', + 'clear', + ]); +}); + it.each([ ['week', '1w'], ['month', '1m'], @@ -129,6 +179,7 @@ function renderSidebar(props: Partial = {}) { referencedLanguages={{}} referencedRules={{}} referencedUsers={{}} + showAllFilters={true} {...props} /> ); diff --git a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx index 24654152bb9..d52b7ac367c 100644 --- a/server/sonar-web/src/main/js/apps/issues/test-utils.tsx +++ b/server/sonar-web/src/main/js/apps/issues/test-utils.tsx @@ -89,6 +89,8 @@ export const ui = { dateInputYearSelect: byRole('combobox', { name: 'Year:' }), clearAllFilters: byRole('button', { name: 'clear_all_filters' }), + showFiltersButton: (showMore = true) => + byRole('button', { name: `issues.show_${showMore ? 'more' : 'less'}_filters` }), ruleFacetList: byRole('list', { name: 'rules' }), languageFacetList: byRole('list', { name: 'languages' }), diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 4b754802297..6da9c013536 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -989,6 +989,8 @@ issues.no_issues=No Issues. Hooray! issues.x_more_locations=+ {0} more locations issues.not_all_issue_show=Not all issues are included issues.not_all_issue_show_why=You do not have access to all projects in this portfolio +issues.show_more_filters=Show more filters +issues.show_less_filters=Show less filters #------------------------------------------------------------------------------ # -- 2.39.5