aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
author7PH <benjamin.raymond@sonarsource.com>2023-04-20 16:50:05 +0200
committersonartech <sonartech@sonarsource.com>2023-04-25 20:03:00 +0000
commitfc5bd48d213b39a83a3828eaec1a9e89b5b88fbd (patch)
tree0d81471df337e39de2e3c750f6a73852415cca4a /server/sonar-web
parent42cb161d1bfde44f942a0347057d916ea2113713 (diff)
downloadsonarqube-fc5bd48d213b39a83a3828eaec1a9e89b5b88fbd.tar.gz
sonarqube-fc5bd48d213b39a83a3828eaec1a9e89b5b88fbd.zip
SONAR-19069 Add Fit for Development and Fit for Production facets
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/issues.ts1
-rw-r--r--server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts46
-rw-r--r--server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx23
-rw-r--r--server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts2
-rw-r--r--server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx70
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx167
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx37
-rw-r--r--server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx12
-rw-r--r--server/sonar-web/src/main/js/apps/issues/test-utils.tsx5
-rw-r--r--server/sonar-web/src/main/js/apps/issues/utils.ts22
-rw-r--r--server/sonar-web/src/main/js/helpers/mocks/issues.ts1
12 files changed, 321 insertions, 67 deletions
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<void> => {
//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<Dict<SnippetsByComponent>> => {
@@ -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<boolean>;
+ openFacets: OpenFacets;
openIssue?: Issue;
openPopup?: { issue: string; name: string };
openRuleDetails?: RuleDetails;
@@ -167,16 +169,19 @@ export class App extends React.PureComponent<Props, State> {
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<Props, State> {
}));
};
- 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<Query>) => void;
+ onToggle: (property: string) => void;
+ open: boolean;
+ stats: Dict<number> | undefined;
+ fitFor: string;
+ characteristics: IssueCharacteristic[];
+}
+
+export default class CharacteristicFacet extends React.PureComponent<Props> {
+ 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 (
+ <FacetItem
+ active={active}
+ key={characteristic}
+ name={
+ <span className="display-flex-center">
+ <IssueTypeIcon className="little-spacer-right" query={characteristic} />{' '}
+ {translate('issue.characteristic', characteristic)}
+ </span>
+ }
+ 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 (
+ <FacetBox property={this.property}>
+ <FacetHeader
+ fetching={this.props.fetching}
+ name={translate('issues.facet.characteristics', fitFor)}
+ onClear={this.handleClear}
+ onClick={this.handleHeaderClick}
+ open={this.props.open}
+ values={values}
+ />
+
+ {this.props.open && (
+ <>
+ <FacetItemsList>{availableCharacteristics.map(this.renderItem)}</FacetItemsList>
+ <MultipleSelectionHint
+ options={Object.keys(availableCharacteristics).length}
+ values={values.length}
+ />
+ </>
+ )}
+ </FacetBox>
+ );
+ }
+}
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<Facet | undefined>;
+ facets: Dict<Facet>;
loadSearchResultCount: (property: string, changes: Partial<Query>) => Promise<Facet>;
loadingFacets: Dict<boolean>;
myIssues: boolean;
onFacetToggle: (property: string) => void;
onFilterChange: (changes: Partial<Query>) => void;
- openFacets: Dict<boolean>;
+ openFacets: OpenFacets;
query: Query;
referencedComponentsById: Dict<ReferencedComponent>;
referencedComponentsByKey: Dict<ReferencedComponent>;
@@ -147,13 +150,23 @@ export class Sidebar extends React.PureComponent<Props> {
newCodeSelected={query.inNewCodePeriod}
/>
)}
- <TypeFacet
- fetching={this.props.loadingFacets.types === true}
+ <CharacteristicFacet
+ fetching={this.props.loadingFacets.characteristics === true}
onChange={this.props.onFilterChange}
onToggle={this.props.onFacetToggle}
- open={!!openFacets.types}
- stats={facets.types}
- types={query.types}
+ open={openFacets.characteristics?.[IssueCharacteristicFitFor.Production]}
+ stats={facets.characteristics}
+ fitFor={IssueCharacteristicFitFor.Production}
+ characteristics={query.characteristics as IssueCharacteristic[]}
+ />
+ <CharacteristicFacet
+ fetching={this.props.loadingFacets.characteristics === true}
+ onChange={this.props.onFilterChange}
+ onToggle={this.props.onFacetToggle}
+ open={openFacets.characteristics?.[IssueCharacteristicFitFor.Development]}
+ stats={facets.characteristics}
+ fitFor={IssueCharacteristicFitFor.Development}
+ characteristics={query.characteristics as IssueCharacteristic[]}
/>
<SeverityFacet
fetching={this.props.loadingFacets.severities === true}
@@ -163,6 +176,14 @@ export class Sidebar extends React.PureComponent<Props> {
severities={query.severities}
stats={facets.severities}
/>
+ <TypeFacet
+ fetching={this.props.loadingFacets.types === true}
+ onChange={this.props.onFilterChange}
+ onToggle={this.props.onFacetToggle}
+ open={!!openFacets.types}
+ stats={facets.types}
+ types={query.types}
+ />
<ScopeFacet
fetching={this.props.loadingFacets.scopes === true}
onChange={this.props.onFilterChange}
diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
index 52fdec70fee..a02ea0f3be6 100644
--- a/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
+++ b/server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
@@ -30,8 +30,10 @@ import { Sidebar } from '../Sidebar';
it('should render correct facets for Application', () => {
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<boolean | Dict<boolean>> & {
+ characteristics?: Dict<boolean>;
+};
+
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<boolean>,
- query: Partial<Query>
-): boolean {
+export function shouldOpenStandardsFacet(openFacets: OpenFacets, query: Partial<Query>): boolean {
return (
- openFacets[STANDARDS] ||
+ !!openFacets[STANDARDS] ||
isFilteredBySecurityIssueTypes(query) ||
isOneStandardChildFacetOpen(openFacets, query)
);
}
export function shouldOpenStandardsChildFacet(
- openFacets: Dict<boolean>,
+ openFacets: OpenFacets,
query: Partial<Query>,
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<boolean>,
+ openFacets: OpenFacets,
query: Partial<Query>
): boolean {
// Open it by default if the parent is open, and no other standard is open.
@@ -287,7 +291,7 @@ function isFilteredBySecurityIssueTypes(query: Partial<Query>): boolean {
return query.types !== undefined && query.types.includes('VULNERABILITY');
}
-function isOneStandardChildFacetOpen(openFacets: Dict<boolean>, query: Partial<Query>): boolean {
+function isOneStandardChildFacetOpen(openFacets: OpenFacets, query: Partial<Query>): 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> = {}): Query {
assigned: false,
assignees: [],
author: [],
+ characteristics: [],
createdAfter: undefined,
createdAt: '',
createdBefore: undefined,