]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19069 Add Fit for Development and Fit for Production facets
author7PH <benjamin.raymond@sonarsource.com>
Thu, 20 Apr 2023 14:50:05 +0000 (16:50 +0200)
committersonartech <sonartech@sonarsource.com>
Tue, 25 Apr 2023 20:03:00 +0000 (20:03 +0000)
13 files changed:
server/sonar-web/src/main/js/api/issues.ts
server/sonar-web/src/main/js/api/mocks/IssuesServiceMock.ts
server/sonar-web/src/main/js/apps/component-measures/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/__tests__/IssuesApp-it.tsx
server/sonar-web/src/main/js/apps/issues/__tests__/utils-test.ts
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/CharacteristicFacet.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/__tests__/Sidebar-it.tsx
server/sonar-web/src/main/js/apps/issues/test-utils.tsx
server/sonar-web/src/main/js/apps/issues/utils.ts
server/sonar-web/src/main/js/helpers/mocks/issues.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index a0675a1dc09437213f65df11e9c3ac8e3ce823a0..b4d6b407ad65fd6c6ffe3dc87619c1d50432a709 100644 (file)
@@ -35,6 +35,7 @@ type FacetName =
   | 'assigned_to_me'
   | 'assignees'
   | 'author'
+  | 'characteristics'
   | 'createdAt'
   | 'cwe'
   | 'directories'
index 73e33083b283b800b623393bb429562dbd4ddb99..3304e1afd01862cda92d2bc93f1f228bf17233cc 100644 (file)
@@ -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 = () => {
index 7ffcfb03c8d76a2775305638bf164a4fa0ffaee0..1a5c8fdbdfc407bf76d93557b0ad051e7df24614 100644 (file)
@@ -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';
 
index d90c6b6f4f8a3aaa236b5c499d1c15f97a203abd..113bd3addf5b87b4796601c5f916bba0ff5b879d 100644 (file)
@@ -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();
index 9598f11890493a63d89f7efdc0d25a8b08b3218d..01dd613a71af516461a0b7cbc2255cd3cc567458 100644 (file)
@@ -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',
index ffad87ea9bb965964eba1e27348570b90176a4bd..dc3e038eb770f33a82d961a4790705b86bfe436b 100644 (file)
@@ -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 (file)
index 0000000..094f3e2
--- /dev/null
@@ -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>
+    );
+  }
+}
index 90460b7b8aaf6a48c4003a11d7ee09dfdd2c285b..8f25db0785b9742c47976f3170574beefc7e5e07 100644 (file)
@@ -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}
index 52fdec70fee967f376008f5f5697d98cee424c38..a02ea0f3be6fef937a662a531cf1b333d7977ade 100644 (file)
@@ -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',
index 04f53c899df73fe62006e6da3f613b14599ef90c..24654152bb979e2805869b0514c52e8441238e40 100644 (file)
@@ -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' }),
index 400718aa38177c2819c32b8678e048fad48e99da..c6b12923b24b00a0863c89cac23e431260f62c8b 100644 (file)
@@ -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:
index 7eb14844271181ad7e161ddae8ee4f3112b726e9..2f0e74cd3fb13af52da2de130ec437e0cf4382e7 100644 (file)
@@ -71,6 +71,7 @@ export function mockQuery(overrides: Partial<Query> = {}): Query {
     assigned: false,
     assignees: [],
     author: [],
+    characteristics: [],
     createdAfter: undefined,
     createdAt: '',
     createdBefore: undefined,
index 60d6e5daaa12b6e3bac9ed287e132fdcf2d141ef..4b7548022979d72bc739144a1c014679d94842da 100644 (file)
@@ -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