From fc5bd48d213b39a83a3828eaec1a9e89b5b88fbd Mon Sep 17 00:00:00 2001 From: 7PH Date: Thu, 20 Apr 2023 16:50:05 +0200 Subject: [PATCH] SONAR-19069 Add Fit for Development and Fit for Production facets --- server/sonar-web/src/main/js/api/issues.ts | 1 + .../main/js/api/mocks/IssuesServiceMock.ts | 46 +++-- .../component-measures/sidebar/Sidebar.tsx | 2 +- .../js/apps/issues/__tests__/IssuesApp-it.tsx | 23 ++- .../js/apps/issues/__tests__/utils-test.ts | 2 + .../js/apps/issues/components/IssuesApp.tsx | 70 +++++--- .../issues/sidebar/CharacteristicFacet.tsx | 167 ++++++++++++++++++ .../main/js/apps/issues/sidebar/Sidebar.tsx | 37 +++- .../issues/sidebar/__tests__/Sidebar-it.tsx | 12 +- .../src/main/js/apps/issues/test-utils.tsx | 5 + .../src/main/js/apps/issues/utils.ts | 22 ++- .../src/main/js/helpers/mocks/issues.ts | 1 + .../resources/org/sonar/l10n/core.properties | 2 + 13 files changed, 323 insertions(+), 67 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx diff --git a/server/sonar-web/src/main/js/api/issues.ts b/server/sonar-web/src/main/js/api/issues.ts index a0675a1dc09..b4d6b407ad6 100644 --- a/server/sonar-web/src/main/js/api/issues.ts +++ b/server/sonar-web/src/main/js/api/issues.ts @@ -35,6 +35,7 @@ type FacetName = | 'assigned_to_me' | 'assignees' | 'author' + | 'characteristics' | 'createdAt' | 'cwe' | 'directories' diff --git a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts index 73e33083b28..3304e1afd01 100644 --- a/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts @@ -29,10 +29,12 @@ import { mockRawIssue, mockRule, mockRuleDetails, + mockUser, } from '../../helpers/testMocks'; import { ASSIGNEE_ME, IssueActions, + IssueCharacteristic, IssueResolution, IssueScope, IssueSeverity, @@ -62,8 +64,8 @@ import { editIssueComment, getIssueChangelog, getIssueFlowSnippets, - searchIssues, searchIssueTags, + searchIssues, setIssueAssignee, setIssueSeverity, setIssueTags, @@ -118,6 +120,7 @@ export default class IssuesServiceMock { component: 'foo:test1.js', creationDate: '2023-01-05T09:36:01+0100', message: 'Issue with no location message', + characteristic: IssueCharacteristic.Secure, type: IssueType.Vulnerability, rule: 'simpleRuleId', textRange: { @@ -179,6 +182,7 @@ export default class IssuesServiceMock { key: 'issue11', component: 'foo:test1.js', message: 'FlowIssue', + characteristic: IssueCharacteristic.Clear, type: IssueType.CodeSmell, severity: IssueSeverity.Minor, rule: 'simpleRuleId', @@ -275,6 +279,7 @@ export default class IssuesServiceMock { component: 'foo:test1.js', message: 'Issue on file', assignee: mockLoggedInUser().login, + characteristic: IssueCharacteristic.Clear, type: IssueType.CodeSmell, rule: 'simpleRuleId', textRange: undefined, @@ -288,6 +293,7 @@ export default class IssuesServiceMock { key: 'issue1', component: 'foo:huge.js', message: 'Fix this', + characteristic: IssueCharacteristic.Secure, type: IssueType.Vulnerability, rule: 'simpleRuleId', textRange: { @@ -475,23 +481,23 @@ export default class IssuesServiceMock { this.list = cloneDeep(this.defaultList); - (searchIssues as jest.Mock).mockImplementation(this.handleSearchIssues); - (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails); + jest.mocked(searchIssues).mockImplementation(this.handleSearchIssues); + jest.mocked(getRuleDetails).mockImplementation(this.handleGetRuleDetails); jest.mocked(searchRules).mockImplementation(this.handleSearchRules); - (getIssueFlowSnippets as jest.Mock).mockImplementation(this.handleGetIssueFlowSnippets); - (bulkChangeIssues as jest.Mock).mockImplementation(this.handleBulkChangeIssues); - (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser); - (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification); - (setIssueType as jest.Mock).mockImplementation(this.handleSetIssueType); + jest.mocked(getIssueFlowSnippets).mockImplementation(this.handleGetIssueFlowSnippets); + jest.mocked(bulkChangeIssues).mockImplementation(this.handleBulkChangeIssues); + jest.mocked(getCurrentUser).mockImplementation(this.handleGetCurrentUser); + jest.mocked(dismissNotice).mockImplementation(this.handleDismissNotification); + jest.mocked(setIssueType).mockImplementation(this.handleSetIssueType); jest.mocked(setIssueAssignee).mockImplementation(this.handleSetIssueAssignee); - (setIssueSeverity as jest.Mock).mockImplementation(this.handleSetIssueSeverity); - (setIssueTransition as jest.Mock).mockImplementation(this.handleSetIssueTransition); - (setIssueTags as jest.Mock).mockImplementation(this.handleSetIssueTags); + jest.mocked(setIssueSeverity).mockImplementation(this.handleSetIssueSeverity); + jest.mocked(setIssueTransition).mockImplementation(this.handleSetIssueTransition); + jest.mocked(setIssueTags).mockImplementation(this.handleSetIssueTags); jest.mocked(addIssueComment).mockImplementation(this.handleAddComment); jest.mocked(editIssueComment).mockImplementation(this.handleEditComment); jest.mocked(deleteIssueComment).mockImplementation(this.handleDeleteComment); - (searchUsers as jest.Mock).mockImplementation(this.handleSearchUsers); - (searchIssueTags as jest.Mock).mockImplementation(this.handleSearchIssueTags); + jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers); + jest.mocked(searchIssueTags).mockImplementation(this.handleSearchIssueTags); jest.mocked(getIssueChangelog).mockImplementation(this.handleGetIssueChangelog); } @@ -527,14 +533,14 @@ export default class IssuesServiceMock { this.isAdmin = isAdmin; } - handleBulkChangeIssues = (issueKeys: string[], query: RequestData) => { + handleBulkChangeIssues = (issueKeys: string[], query: RequestData): Promise => { //For now we only check for issue severity change. this.list .filter((i) => issueKeys.includes(i.issue.key)) .forEach((data) => { data.issue.severity = query.set_severity; }); - return this.reply({}); + return this.reply(undefined); }; handleGetIssueFlowSnippets = (issueKey: string): Promise> => { @@ -739,6 +745,11 @@ export default class IssuesServiceMock { (item) => !query.createdAfter || new Date(item.issue.creationDate) >= new Date(query.createdAfter) ) + .filter( + (item) => + !query.characteristics || + query.characteristics.split(',').includes(item.issue.characteristic) + ) .filter((item) => !query.types || query.types.split(',').includes(item.issue.type)) .filter( (item) => !query.severities || query.severities.split(',').includes(item.issue.severity) @@ -911,7 +922,10 @@ export default class IssuesServiceMock { }; handleSearchUsers = () => { - return this.reply({ users: [mockLoggedInUser()] }); + return this.reply({ + paging: mockPaging({ pageIndex: 1, pageSize: 5, total: 1 }), + users: [mockUser({ login: 'luke', name: 'Skywalker' })], + }); }; handleSearchIssueTags = () => { diff --git a/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx index 7ffcfb03c8d..1a5c8fdbdfc 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; import { translate } from '../../../helpers/l10n'; import { Dict, MeasureEnhanced } from '../../../types/types'; -import { groupByDomains, KNOWN_DOMAINS, PROJECT_OVERVEW, Query } from '../utils'; +import { KNOWN_DOMAINS, PROJECT_OVERVEW, Query, groupByDomains } from '../utils'; import DomainFacet from './DomainFacet'; import ProjectOverviewFacet from './ProjectOverviewFacet'; diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx index d90c6b6f4f8..113bd3addf5 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx @@ -274,6 +274,7 @@ describe('issues app', () => { await waitOnDataLoaded(); // Ensure issue type filter is unchecked + await user.click(ui.typeFacet.get()); expect(ui.codeSmellIssueTypeFilter.get()).not.toBeChecked(); expect(ui.vulnerabilityIssueTypeFilter.get()).not.toBeChecked(); expect(ui.issueItem1.get()).toBeInTheDocument(); @@ -327,7 +328,21 @@ describe('issues app', () => { renderIssueApp(); await waitOnDataLoaded(); - // Select only code smells (should make the first issue disappear) + // Select a characteristic + await user.click(ui.clearCharacteristicFilter.get()); + expect(ui.issueItem1.query()).not.toBeInTheDocument(); + expect(ui.issueItem2.get()).toBeInTheDocument(); + + // Clicking on same filter should uncheck it + await user.click(ui.clearCharacteristicFilter.get()); + expect(ui.issueItem1.get()).toBeInTheDocument(); + expect(ui.issueItem2.get()).toBeInTheDocument(); + + // Select clarity characteristic (should make the first issue disappear) + await user.click(ui.clearCharacteristicFilter.get()); + + // Select only code smells + await user.click(ui.typeFacet.get()); await user.click(ui.codeSmellIssueTypeFilter.get()); // Select code smells + major severity @@ -395,6 +410,7 @@ describe('issues app', () => { expect(ui.issueItem7.get()).toBeInTheDocument(); // Clear filters one by one + await user.click(ui.clearFitForDevelopmentFacet.get()); await user.click(ui.clearIssueTypeFacet.get()); await user.click(ui.clearSeverityFacet.get()); await user.click(ui.clearScopeFacet.get()); @@ -892,11 +908,14 @@ describe('redirects', () => { expect(screen.getByText('/security_hotspots?assignedToMe=false')).toBeInTheDocument(); }); - it('should filter out hotspots', () => { + it('should filter out hotspots', async () => { + const user = userEvent.setup(); renderProjectIssuesApp( `project/issues?types=${IssueType.SecurityHotspot},${IssueType.CodeSmell}` ); + await waitOnDataLoaded(); + await user.click(ui.typeFacet.get()); expect( screen.getByRole('checkbox', { name: `issue.type.${IssueType.CodeSmell}` }) ).toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts index 9598f118904..01dd613a71a 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts @@ -40,6 +40,7 @@ describe('serialize/deserialize', () => { assigned: true, assignees: ['a', 'b'], author: ['a', 'b'], + characteristics: ['a', 'b'], createdAfter: new Date(1000000), createdAt: 'a', createdBefore: new Date(1000000), @@ -71,6 +72,7 @@ describe('serialize/deserialize', () => { ).toStrictEqual({ assignees: 'a,b', author: ['a', 'b'], + characteristics: 'a,b', createdAt: 'a', createdBefore: '1970-01-01', createdAfter: '1970-01-01', 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 ffad87ea9bb..dc3e038eb77 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 @@ -19,7 +19,7 @@ */ import styled from '@emotion/styled'; import classNames from 'classnames'; -import { debounce, keyBy, omit, without } from 'lodash'; +import { debounce, get, keyBy, omit, set, without } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import { FormattedMessage } from 'react-intl'; @@ -69,6 +69,7 @@ import { ASSIGNEE_ME, Facet, FetchIssuesPromise, + IssueCharacteristicFitFor, ReferencedComponent, ReferencedLanguage, ReferencedRule, @@ -82,6 +83,7 @@ import ConciseIssuesListHeader from '../conciseIssuesList/ConciseIssuesListHeade import Sidebar from '../sidebar/Sidebar'; import '../styles.css'; import { + OpenFacets, Query, STANDARDS, areMyIssuesSelected, @@ -127,7 +129,7 @@ export interface State { loadingMore: boolean; locationsNavigator: boolean; myIssues: boolean; - openFacets: Dict; + openFacets: OpenFacets; openIssue?: Issue; openPopup?: { issue: string; name: string }; openRuleDetails?: RuleDetails; @@ -167,16 +169,19 @@ export class App extends React.PureComponent { locationsNavigator: false, myIssues: areMyIssuesSelected(props.location.query), openFacets: { + characteristics: { + [IssueCharacteristicFitFor.Production]: true, + [IssueCharacteristicFitFor.Development]: true, + }, + severities: true, owaspTop10: shouldOpenStandardsChildFacet({}, query, SecurityStandard.OWASP_TOP10), 'owaspTop10-2021': shouldOpenStandardsChildFacet( {}, query, SecurityStandard.OWASP_TOP10_2021 ), - severities: true, sonarsourceSecurity: shouldOpenSonarSourceSecurityFacet({}, query), standards: shouldOpenStandardsFacet({}, query), - types: true, }, query, referencedComponentsById: {}, @@ -700,32 +705,41 @@ export class App extends React.PureComponent { })); }; - handleFacetToggle = (property: string) => { - this.setState((state) => { - const willOpenProperty = !state.openFacets[property]; - const newState = { - loadingFacets: state.loadingFacets, - openFacets: { ...state.openFacets, [property]: willOpenProperty }, - }; - - // Try to open sonarsource security "subfacet" by default if the standard facet is open - if (willOpenProperty && property === STANDARDS) { - newState.openFacets.sonarsourceSecurity = shouldOpenSonarSourceSecurityFacet( - newState.openFacets, - state.query - ); - // Force loading of sonarsource security facet data - property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property; - } + /** + * @param property Facet property within openFacets. Can be a path, e.g. 'characteristics.PRODUCTION' + */ + handleFacetToggle = async (property: string) => { + const willOpenProperty = !get(this.state.openFacets, property); + const newState = { + loadingFacets: this.state.loadingFacets, + openFacets: { ...this.state.openFacets }, + }; + set(newState.openFacets, property, willOpenProperty); - // No need to load facets data for standard facet - if (property !== STANDARDS && !state.facets[property]) { - newState.loadingFacets[property] = true; - this.fetchFacet(property); - } + // Try to open sonarsource security "subfacet" by default if the standard facet is open + if (property === STANDARDS && willOpenProperty) { + newState.openFacets.sonarsourceSecurity = shouldOpenSonarSourceSecurityFacet( + newState.openFacets, + this.state.query + ); + // Force loading of sonarsource security facet data + property = newState.openFacets.sonarsourceSecurity ? 'sonarsourceSecurity' : property; + } - return newState; - }); + // No need to load facets data for standard facet + if (property !== STANDARDS) { + newState.loadingFacets[property] = true; + } + + this.setState(newState); + + // No need to load facets data for standard facet + if (property !== STANDARDS) { + // Fetch facet from the backend, only keeping first level of the property, + // eg will send 'characteristics' for property 'characteristics.PRODUCTION' + const facetName = property.split('.')[0]; + await this.fetchFacet(facetName); + } }; handleReset = () => { 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 new file mode 100644 index 00000000000..094f3e27c72 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx @@ -0,0 +1,167 @@ +/* + * 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 { orderBy, without } from 'lodash'; +import * as React from 'react'; +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 MultipleSelectionHint from '../../../components/facet/MultipleSelectionHint'; +import IssueTypeIcon from '../../../components/icons/IssueTypeIcon'; +import { translate } from '../../../helpers/l10n'; +import { ISSUE_CHARACTERISTIC_TO_FIT_FOR, IssueCharacteristic } from '../../../types/issues'; +import { Dict } from '../../../types/types'; +import { Query, formatFacetStat } from '../utils'; + +interface Props { + fetching: boolean; + onChange: (changes: Partial) => void; + onToggle: (property: string) => void; + open: boolean; + stats: Dict | undefined; + fitFor: string; + characteristics: IssueCharacteristic[]; +} + +export default class CharacteristicFacet extends React.PureComponent { + property = 'characteristics'; + + static defaultProps = { + open: true, + }; + + handleItemClick = (itemValue: IssueCharacteristic, multiple: boolean) => { + const { characteristics } = this.props; + if (multiple) { + const newValue = orderBy( + characteristics.includes(itemValue) + ? without(characteristics, itemValue) + : [...characteristics, itemValue] + ); + this.props.onChange({ [this.property]: newValue }); + return; + } + + // Append if there is no characteristic selected yet in this fitFor + const selectedFitFor = characteristics.filter( + (characteristic) => ISSUE_CHARACTERISTIC_TO_FIT_FOR[characteristic] === this.props.fitFor + ); + if (selectedFitFor.length === 0) { + this.props.onChange({ [this.property]: [...characteristics, itemValue] }); + return; + } + + // If clicking on the only selected characteristic, clear it + if (selectedFitFor.length === 1 && selectedFitFor[0] === itemValue) { + this.props.onChange({ + [this.property]: characteristics.filter( + (characteristic) => ISSUE_CHARACTERISTIC_TO_FIT_FOR[characteristic] !== this.props.fitFor + ), + }); + return; + } + + // If there is already a selection for this fitFor, replace it + this.props.onChange({ + [this.property]: characteristics + .filter( + (characteristic) => ISSUE_CHARACTERISTIC_TO_FIT_FOR[characteristic] !== this.props.fitFor + ) + .concat([itemValue]), + }); + }; + + handleHeaderClick = () => { + this.props.onToggle(`${this.property}.${this.props.fitFor}`); + }; + + handleClear = () => { + // Clear characteristics for this fitFor + this.props.onChange({ + [this.property]: this.props.characteristics.filter( + (characteristic) => ISSUE_CHARACTERISTIC_TO_FIT_FOR[characteristic] !== this.props.fitFor + ), + }); + }; + + getStat(characteristic: string) { + const { stats } = this.props; + return stats ? stats[characteristic] : undefined; + } + + isFacetItemActive(characteristic: IssueCharacteristic) { + return this.props.characteristics.includes(characteristic); + } + + renderItem = (characteristic: IssueCharacteristic) => { + const active = this.isFacetItemActive(characteristic); + const stat = this.getStat(characteristic); + + return ( + + {' '} + {translate('issue.characteristic', characteristic)} + + } + onClick={this.handleItemClick} + stat={formatFacetStat(stat)} + value={characteristic} + /> + ); + }; + + render() { + const { characteristics, fitFor } = this.props; + const values = characteristics + .filter((characteristic) => ISSUE_CHARACTERISTIC_TO_FIT_FOR[characteristic] === fitFor) + .map((characteristic) => translate('issue.characteristic', characteristic)); + + const availableCharacteristics = Object.entries(ISSUE_CHARACTERISTIC_TO_FIT_FOR) + .filter(([, value]) => value === fitFor) + .map(([key]) => key as IssueCharacteristic); + + return ( + + + + {this.props.open && ( + <> + {availableCharacteristics.map(this.renderItem)} + + + )} + + ); + } +} 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 90460b7b8aa..8f25db0785b 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 @@ -30,6 +30,8 @@ import { } from '../../../types/component'; import { Facet, + IssueCharacteristic, + IssueCharacteristicFitFor, ReferencedComponent, ReferencedLanguage, ReferencedRule, @@ -37,9 +39,10 @@ import { import { GlobalSettingKeys } from '../../../types/settings'; import { Component, Dict } from '../../../types/types'; import { UserBase } from '../../../types/users'; -import { Query } from '../utils'; +import { OpenFacets, Query } from '../utils'; import AssigneeFacet from './AssigneeFacet'; import AuthorFacet from './AuthorFacet'; +import CharacteristicFacet from './CharacteristicFacet'; import CreationDateFacet from './CreationDateFacet'; import DirectoryFacet from './DirectoryFacet'; import FileFacet from './FileFacet'; @@ -60,13 +63,13 @@ export interface Props { branchLike?: BranchLike; component: Component | undefined; createdAfterIncludesTime: boolean; - facets: Dict; + facets: Dict; loadSearchResultCount: (property: string, changes: Partial) => Promise; loadingFacets: Dict; myIssues: boolean; onFacetToggle: (property: string) => void; onFilterChange: (changes: Partial) => void; - openFacets: Dict; + openFacets: OpenFacets; query: Query; referencedComponentsById: Dict; referencedComponentsByKey: Dict; @@ -147,13 +150,23 @@ export class Sidebar extends React.PureComponent { newCodeSelected={query.inNewCodePeriod} /> )} - + { severities={query.severities} stats={facets.severities} /> + { renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.Application }) }); expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([ - 'issues.facet.types', + 'issues.facet.characteristics.PRODUCTION', + 'issues.facet.characteristics.DEVELOPMENT', 'issues.facet.severities', + 'issues.facet.types', 'issues.facet.scopes', 'issues.facet.resolutions', 'issues.facet.statuses', @@ -50,8 +52,10 @@ it('should render correct facets for Application', () => { it('should render correct facets for Portfolio', () => { renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.Portfolio }) }); expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([ - 'issues.facet.types', + 'issues.facet.characteristics.PRODUCTION', + 'issues.facet.characteristics.DEVELOPMENT', 'issues.facet.severities', + 'issues.facet.types', 'issues.facet.scopes', 'issues.facet.resolutions', 'issues.facet.statuses', @@ -70,8 +74,10 @@ it('should render correct facets for Portfolio', () => { it('should render correct facets for SubPortfolio', () => { renderSidebar({ component: mockComponent({ qualifier: ComponentQualifier.SubPortfolio }) }); expect(screen.getAllByRole('button').map((button) => button.textContent)).toStrictEqual([ - 'issues.facet.types', + 'issues.facet.characteristics.PRODUCTION', + 'issues.facet.characteristics.DEVELOPMENT', 'issues.facet.severities', + 'issues.facet.types', 'issues.facet.scopes', 'issues.facet.resolutions', 'issues.facet.statuses', 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 04f53c899df..24654152bb9 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 @@ -51,6 +51,11 @@ export const ui = { issueItem7: byRole('region', { name: 'Issue with tags' }), issueItem8: byRole('region', { name: 'Issue on page 2' }), + clearFitForDevelopmentFacet: byRole('button', { + name: 'clear_x_filter.issues.facet.characteristics.DEVELOPMENT', + }), + clearCharacteristicFilter: byRole('checkbox', { name: 'issue.characteristic.CLEAR' }), + typeFacet: byRole('button', { name: 'issues.facet.types' }), clearIssueTypeFacet: byRole('button', { name: 'clear_x_filter.issues.facet.types' }), codeSmellIssueTypeFilter: byRole('checkbox', { name: 'issue.type.CODE_SMELL' }), vulnerabilityIssueTypeFilter: byRole('checkbox', { name: 'issue.type.VULNERABILITY' }), 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 400718aa381..c6b12923b24 100644 --- a/server/sonar-web/src/main/js/apps/issues/utils.ts +++ b/server/sonar-web/src/main/js/apps/issues/utils.ts @@ -44,6 +44,7 @@ export interface Query { assigned: boolean; assignees: string[]; author: string[]; + characteristics: string[]; createdAfter: Date | undefined; createdAt: string; createdBefore: Date | undefined; @@ -73,6 +74,10 @@ export interface Query { types: string[]; } +export type OpenFacets = Dict> & { + characteristics?: Dict; +}; + export const STANDARDS = 'standards'; // allow sorting by CREATION_DATE only @@ -84,6 +89,7 @@ export function parseQuery(query: RawQuery): Query { assigned: parseAsBoolean(query.assigned), assignees: parseAsArray(query.assignees, parseAsString), author: isArray(query.author) ? query.author : [query.author].filter(isDefined), + characteristics: parseAsArray(query.characteristics, parseAsString), createdAfter: parseAsDate(query.createdAfter), createdAt: parseAsString(query.createdAt), createdBefore: parseAsDate(query.createdBefore), @@ -130,6 +136,7 @@ export function serializeQuery(query: Query): RawQuery { assigned: query.assigned ? undefined : 'false', assignees: serializeStringArray(query.assignees), author: query.author, + characteristics: serializeStringArray(query.characteristics), createdAfter: serializeDateShort(query.createdAfter), createdAt: serializeString(query.createdAt), createdBefore: serializeDateShort(query.createdBefore), @@ -244,19 +251,16 @@ export function allLocationsEmpty( return getLocations(issue, selectedFlowIndex).every((location) => !location.msg); } -export function shouldOpenStandardsFacet( - openFacets: Dict, - query: Partial -): boolean { +export function shouldOpenStandardsFacet(openFacets: OpenFacets, query: Partial): boolean { return ( - openFacets[STANDARDS] || + !!openFacets[STANDARDS] || isFilteredBySecurityIssueTypes(query) || isOneStandardChildFacetOpen(openFacets, query) ); } export function shouldOpenStandardsChildFacet( - openFacets: Dict, + openFacets: OpenFacets, query: Partial, standardType: | SecurityStandard.CWE @@ -267,13 +271,13 @@ export function shouldOpenStandardsChildFacet( const filter = query[standardType]; return ( openFacets[STANDARDS] !== false && - (openFacets[standardType] || + (!!openFacets[standardType] || (standardType !== SecurityStandard.CWE && filter !== undefined && filter.length > 0)) ); } export function shouldOpenSonarSourceSecurityFacet( - openFacets: Dict, + openFacets: OpenFacets, query: Partial ): boolean { // Open it by default if the parent is open, and no other standard is open. @@ -287,7 +291,7 @@ function isFilteredBySecurityIssueTypes(query: Partial): boolean { return query.types !== undefined && query.types.includes('VULNERABILITY'); } -function isOneStandardChildFacetOpen(openFacets: Dict, query: Partial): boolean { +function isOneStandardChildFacetOpen(openFacets: OpenFacets, query: Partial): boolean { return [SecurityStandard.OWASP_TOP10, SecurityStandard.CWE, SecurityStandard.SONARSOURCE].some( ( standardType: diff --git a/server/sonar-web/src/main/js/helpers/mocks/issues.ts b/server/sonar-web/src/main/js/helpers/mocks/issues.ts index 7eb14844271..2f0e74cd3fb 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/issues.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/issues.ts @@ -71,6 +71,7 @@ export function mockQuery(overrides: Partial = {}): Query { assigned: false, assignees: [], author: [], + characteristics: [], createdAfter: undefined, createdAt: '', createdBefore: undefined, 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 60d6e5daaa1..4b754802297 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1021,6 +1021,8 @@ issue.changelog.field.file=File # #------------------------------------------------------------------------------ issues.facet.period=Period +issues.facet.characteristics.DEVELOPMENT=Fit for Development +issues.facet.characteristics.PRODUCTION=Fit for Production issues.facet.types=Type issues.facet.severities=Severity issues.facet.scopes=Scope -- 2.39.5