]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19236 Move Security Hotspots content to new tabs
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Mon, 15 May 2023 12:26:02 +0000 (14:26 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 24 May 2023 20:03:13 +0000 (20:03 +0000)
19 files changed:
server/sonar-web/design-system/src/components/HtmlFormatter.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/ToggleButton.tsx
server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/helpers/__tests__/tabs-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/helpers/index.ts
server/sonar-web/design-system/src/helpers/tabs.ts [new file with mode: 0644]
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts [deleted file]
server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts
server/sonar-web/src/main/js/app/styles/style.css
server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts
server/sonar-web/src/main/js/apps/coding-rules/styles.css
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx
server/sonar-web/src/main/js/components/rules/RuleDescription.tsx
server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx

diff --git a/server/sonar-web/design-system/src/components/HtmlFormatter.tsx b/server/sonar-web/design-system/src/components/HtmlFormatter.tsx
new file mode 100644 (file)
index 0000000..2142e78
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * 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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers';
+
+export const HtmlFormatter = styled.div`
+  ${tw`sw-my-6`}
+  ${tw`sw-body-sm`}
+
+  a {
+    color: ${themeColor('linkDefault')};
+    border-bottom: ${themeBorder('default', 'linkDefault')};
+    ${tw`sw-no-underline sw-body-sm-highlight`};
+
+    &:visited {
+      color: ${themeColor('linkDefault')};
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      color: ${themeColor('linkActive')};
+      border-bottom: ${themeBorder('default', 'linkDefault')};
+    }
+  }
+
+  p,
+  ul,
+  ol,
+  pre,
+  blockquote,
+  table {
+    color: ${themeColor('pageContent')};
+    ${tw`sw-mb-4`}
+  }
+
+  h2,
+  h3 {
+    color: ${themeColor('pageTitle')};
+    ${tw`sw-heading-md`}
+    ${tw`sw-my-6`}
+  }
+
+  h4,
+  h5,
+  h6 {
+    color: ${themeColor('pageContent')};
+    ${tw`sw-body-md-highlight`}
+    ${tw`sw-mt-6 sw-mb-2`}
+  }
+
+  pre,
+  code {
+    background-color: ${themeColor('codeSnippetBackground')};
+    border: ${themeBorder('default', 'codeSnippetBorder')};
+    ${tw`sw-code`}
+  }
+
+  pre {
+    ${tw`sw-rounded-2`}
+    ${tw`sw-relative`}
+    ${tw`sw-my-2`}
+
+    ${tw`sw-overflow-x-auto`}
+    ${tw`sw-p-6`}
+  }
+
+  code {
+    ${tw`sw-m-0`}
+    /* 1px override is needed to prevent overlap of other code "tags" */
+    ${tw`sw-py-[1px] sw-px-1`}
+    ${tw`sw-rounded-1`}
+    ${tw`sw-whitespace-nowrap`}
+  }
+
+  pre > code {
+    ${tw`sw-p-0`}
+    ${tw`sw-whitespace-pre`}
+    background-color: transparent;
+  }
+
+  blockquote {
+    ${tw`sw-px-4`}
+    line-height: 1.5;
+  }
+
+  ul {
+    ${tw`sw-pl-6`}
+    ${tw`sw-flex sw-flex-col sw-gap-2`}
+    list-style-type: disc;
+
+    li::marker {
+      color: ${themeColor('listMarker')};
+    }
+  }
+
+  li > ul {
+    ${tw`sw-my-2 sw-mx-0`}
+  }
+
+  ol {
+    ${tw`sw-pl-10`};
+    list-style-type: decimal;
+  }
+
+  table {
+    ${tw`sw-min-w-[50%]`}
+    border: ${themeBorder('default')};
+    border-collapse: collapse;
+  }
+
+  th {
+    ${tw`sw-py-1 sw-px-3`}
+    ${tw`sw-body-sm-highlight`}
+    ${tw`sw-text-center`}
+    background-color: ${themeColor('backgroundPrimary')};
+    border: ${themeBorder('default')};
+  }
+
+  td {
+    ${tw`sw-py-1 sw-px-3`}
+    border: ${themeBorder('default')};
+  }
+`;
index 4ae54df92cbf1352065d5b8f54235be51ca4196b..6291bdd2102967ad7cdff4412750bc026f21a12c 100644 (file)
@@ -20,6 +20,7 @@
 
 import styled from '@emotion/styled';
 import tw from 'twin.macro';
+import { getTabId, getTabPanelId } from '../helpers/tabs';
 import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { Badge } from './Badge';
 import { ButtonSecondary } from './buttons';
@@ -38,26 +39,30 @@ export interface ButtonToggleProps<T extends ToggleButtonValueType> {
   label?: string;
   onChange: (value: T) => void;
   options: Array<ToggleButtonsOption<T>>;
+  role?: 'radiogroup' | 'tablist';
   value?: T;
 }
 
 export function ToggleButton<T extends ToggleButtonValueType>(props: ButtonToggleProps<T>) {
-  const { disabled = false, label, options, value } = props;
+  const { disabled = false, label, options, value, role = 'radiogroup' } = props;
+  const isRadioGroup = role === 'radiogroup';
 
   return (
-    <Wrapper aria-label={label} role="radiogroup">
+    <Wrapper aria-label={label} role={role}>
       {options.map((option) => (
         <OptionButton
+          aria-controls={isRadioGroup ? undefined : getTabPanelId(String(option.value))}
           aria-current={option.value === value}
           data-value={option.value}
           disabled={disabled || option.disabled}
+          id={getTabId(String(option.value))}
           key={option.value.toString()}
           onClick={() => {
             if (option.value !== value) {
               props.onChange(option.value);
             }
           }}
-          role="radio"
+          role={isRadioGroup ? 'radio' : 'tab'}
           selected={option.value === value}
         >
           {option.label}
index c691211c7ead1f92980f80657f94da365e2c0d97..b34ae78d4b8c1ad164e9da8dc327bcc954048a8a 100644 (file)
  */
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import { getTabPanelId } from '../../helpers';
 import { render } from '../../helpers/testUtils';
 import { FCProps } from '../../types/misc';
 import { ToggleButton, ToggleButtonsOption } from '../ToggleButton';
 
 it('should render all options', async () => {
   const user = userEvent.setup();
-
   const onChange = jest.fn();
-
   const options: Array<ToggleButtonsOption<number>> = [
     { value: 1, label: 'first' },
     { value: 2, label: 'disabled', disabled: true },
     { value: 3, label: 'has counter', counter: 7 },
   ];
-
   renderToggleButtons({ onChange, options, value: 1 });
 
   expect(screen.getAllByRole('radio')).toHaveLength(3);
@@ -47,6 +45,22 @@ it('should render all options', async () => {
   expect(onChange).toHaveBeenCalledWith(3);
 });
 
+it('should work in tablist mode', () => {
+  const onChange = jest.fn();
+  const options: Array<ToggleButtonsOption<number>> = [
+    { value: 1, label: 'first' },
+    { value: 2, label: 'second' },
+    { value: 3, label: 'third' },
+  ];
+  renderToggleButtons({ onChange, options, value: 1, role: 'tablist' });
+
+  expect(screen.getAllByRole('tab')).toHaveLength(3);
+  expect(screen.getByRole('tab', { name: 'second' })).toHaveAttribute(
+    'aria-controls',
+    getTabPanelId(2)
+  );
+});
+
 function renderToggleButtons(props: Partial<FCProps<typeof ToggleButton>> = {}) {
   return render(<ToggleButton onChange={jest.fn()} options={[]} {...props} />);
 }
index 51a75a9969cb31a899916f5918fd4b61625480ff..edff1756a63348fce6d1e00f8716cd6e4287969d 100644 (file)
@@ -42,6 +42,7 @@ export * from './FlowStep';
 export * from './GenericAvatar';
 export * from './HighlightedSection';
 export { HotspotRating } from './HotspotRating';
+export * from './HtmlFormatter';
 export * from './InputField';
 export { InputSearch } from './InputSearch';
 export * from './InputSelect';
diff --git a/server/sonar-web/design-system/src/helpers/__tests__/tabs-test.ts b/server/sonar-web/design-system/src/helpers/__tests__/tabs-test.ts
new file mode 100644 (file)
index 0000000..0512fdc
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * 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 { getTabId, getTabPanelId } from '../tabs';
+
+it('should correctly generate IDs', () => {
+  expect(getTabId('ID')).toBe('tab-ID');
+  expect(getTabPanelId('ID')).toBe('tabpanel-ID');
+});
index 5e62e8b766f105fc5851997f8d800f7aece1d6de..541abac6a36c8da1806f3123a6915531f25402b2 100644 (file)
@@ -21,4 +21,5 @@
 export * from './colors';
 export * from './constants';
 export * from './positioning';
+export * from './tabs';
 export * from './theme';
diff --git a/server/sonar-web/design-system/src/helpers/tabs.ts b/server/sonar-web/design-system/src/helpers/tabs.ts
new file mode 100644 (file)
index 0000000..1ef3fcc
--- /dev/null
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+export function getTabPanelId(key: string | number) {
+  return `tabpanel-${key}`;
+}
+
+export function getTabId(key: string | number) {
+  return `tab-${key}`;
+}
index 4854e46adb7fe9ec3ee6cf56d622ef3e19f96d56..ea90096897cd4571795ddef4a53eebfce6f4bc32 100644 (file)
@@ -196,6 +196,8 @@ export const lightTheme = {
     codeLineLocationMarker: COLORS.red[200],
     codeLineLocationMarkerSelected: danger.lighter,
     codeLineLocationSelected: COLORS.blueGrey[100],
+    codeLineCoveredUnderline: [...COLORS.green[500], 0.15],
+    codeLineUncoveredUnderline: [...COLORS.red[500], 0.15],
 
     // checkbox
     checkboxHover: COLORS.indigo[50],
diff --git a/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts b/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts
deleted file mode 100644 (file)
index 312b864..0000000
+++ /dev/null
@@ -1,422 +0,0 @@
-/*
- * 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 { cloneDeep, countBy, pick, trim } from 'lodash';
-import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
-import {
-  mockCurrentUser,
-  mockPaging,
-  mockQualityProfile,
-  mockRuleDetails,
-  mockRuleRepository,
-} from '../../helpers/testMocks';
-import { RuleRepository, SearchRulesResponse } from '../../types/coding-rules';
-import { RawIssuesResponse } from '../../types/issues';
-import { SearchRulesQuery } from '../../types/rules';
-import { Rule, RuleActivation, RuleDetails, RulesUpdateRequest } from '../../types/types';
-import { NoticeType } from '../../types/users';
-import { getFacet } from '../issues';
-import {
-  Profile,
-  SearchQualityProfilesParameters,
-  SearchQualityProfilesResponse,
-  bulkActivateRules,
-  bulkDeactivateRules,
-  searchQualityProfiles,
-} from '../quality-profiles';
-import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
-import { dismissNotice, getCurrentUser } from '../users';
-
-interface FacetFilter {
-  languages?: string;
-  available_since?: string;
-}
-
-const FACET_RULE_MAP: { [key: string]: keyof Rule } = {
-  languages: 'lang',
-  types: 'type',
-};
-export default class CodingRulesMock {
-  defaultRules: RuleDetails[] = [];
-  rules: RuleDetails[] = [];
-  qualityProfile: Profile[] = [];
-  repositories: RuleRepository[] = [];
-  isAdmin = false;
-  applyWithWarning = false;
-  dismissedNoticesEP = false;
-
-  constructor() {
-    this.repositories = [
-      mockRuleRepository({ key: 'repo1' }),
-      mockRuleRepository({ key: 'repo2' }),
-    ];
-    this.qualityProfile = [
-      mockQualityProfile({ key: 'p1', name: 'QP Foo', language: 'java', languageName: 'Java' }),
-      mockQualityProfile({ key: 'p2', name: 'QP Bar', language: 'js' }),
-      mockQualityProfile({ key: 'p3', name: 'QP FooBar', language: 'java', languageName: 'Java' }),
-    ];
-
-    const resourceContent = 'Some link <a href="http://example.com">Awsome Reading</a>';
-    const introTitle = 'Introduction to this rule';
-    const rootCauseContent = 'This how to fix';
-
-    this.defaultRules = [
-      mockRuleDetails({
-        key: 'rule1',
-        type: 'BUG',
-        lang: 'java',
-        langName: 'Java',
-        name: 'Awsome java rule',
-      }),
-      mockRuleDetails({
-        key: 'rule2',
-        name: 'Hot hotspot',
-        type: 'SECURITY_HOTSPOT',
-        lang: 'js',
-        descriptionSections: [
-          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
-          { key: RuleDescriptionSections.ROOT_CAUSE, content: rootCauseContent },
-          { key: RuleDescriptionSections.ASSESS_THE_PROBLEM, content: 'Assess' },
-          {
-            key: RuleDescriptionSections.RESOURCES,
-            content: resourceContent,
-          },
-        ],
-        langName: 'JavaScript',
-      }),
-      mockRuleDetails({ key: 'rule3', name: 'Unknown rule', lang: 'js', langName: 'JavaScript' }),
-      mockRuleDetails({
-        key: 'rule4',
-        type: 'BUG',
-        lang: 'c',
-        langName: 'C',
-        name: 'Awsome C rule',
-      }),
-      mockRuleDetails({
-        key: 'rule5',
-        type: 'VULNERABILITY',
-        lang: 'py',
-        langName: 'Python',
-        name: 'Awsome Python rule',
-        descriptionSections: [
-          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
-          { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
-          {
-            key: RuleDescriptionSections.RESOURCES,
-            content: resourceContent,
-          },
-        ],
-      }),
-      mockRuleDetails({
-        key: 'rule6',
-        type: 'VULNERABILITY',
-        lang: 'py',
-        langName: 'Python',
-        name: 'Bad Python rule',
-        isExternal: true,
-        descriptionSections: undefined,
-      }),
-      mockRuleDetails({
-        key: 'rule7',
-        type: 'VULNERABILITY',
-        lang: 'py',
-        langName: 'Python',
-        name: 'Python rule with context',
-        descriptionSections: [
-          {
-            key: RuleDescriptionSections.INTRODUCTION,
-            content: 'Introduction to this rule with context',
-          },
-          {
-            key: RuleDescriptionSections.HOW_TO_FIX,
-            content: 'This is how to fix for spring',
-            context: { key: 'spring', displayName: 'Spring' },
-          },
-          {
-            key: RuleDescriptionSections.HOW_TO_FIX,
-            content: 'This is how to fix for spring boot',
-            context: { key: 'spring_boot', displayName: 'Spring boot' },
-          },
-          {
-            key: RuleDescriptionSections.RESOURCES,
-            content: resourceContent,
-          },
-        ],
-      }),
-      mockRuleDetails({
-        createdAt: '2022-12-16T17:26:54+0100',
-        key: 'rule8',
-        type: 'VULNERABILITY',
-        lang: 'py',
-        langName: 'Python',
-        name: 'Awesome Python rule with education principles',
-        descriptionSections: [
-          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
-          { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
-          {
-            key: RuleDescriptionSections.RESOURCES,
-            content: resourceContent,
-          },
-        ],
-        educationPrinciples: ['defense_in_depth', 'never_trust_user_input'],
-      }),
-    ];
-
-    (updateRule as jest.Mock).mockImplementation(this.handleUpdateRule);
-    (searchRules as jest.Mock).mockImplementation(this.handleSearchRules);
-    (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
-    (searchQualityProfiles as jest.Mock).mockImplementation(this.handleSearchQualityProfiles);
-    (getRulesApp as jest.Mock).mockImplementation(this.handleGetRulesApp);
-    (bulkActivateRules as jest.Mock).mockImplementation(this.handleBulkActivateRules);
-    (bulkDeactivateRules as jest.Mock).mockImplementation(this.handleBulkDeactivateRules);
-    (getFacet as jest.Mock).mockImplementation(this.handleGetGacet);
-    (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
-    (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
-    this.rules = cloneDeep(this.defaultRules);
-  }
-
-  getRulesWithoutDetails(rules: RuleDetails[]) {
-    return rules.map((r) =>
-      pick(r, [
-        'isTemplate',
-        'key',
-        'lang',
-        'langName',
-        'name',
-        'params',
-        'severity',
-        'status',
-        'sysTags',
-        'tags',
-        'type',
-      ])
-    );
-  }
-
-  filterFacet({ languages, available_since }: FacetFilter) {
-    let filteredRules = this.rules;
-    if (languages) {
-      filteredRules = filteredRules.filter((r) => r.lang && languages.includes(r.lang));
-    }
-    if (available_since) {
-      filteredRules = filteredRules.filter(
-        (r) => r.createdAt && new Date(r.createdAt) > new Date(available_since)
-      );
-    }
-    return this.getRulesWithoutDetails(filteredRules);
-  }
-
-  setIsAdmin() {
-    this.isAdmin = true;
-  }
-
-  activateWithWarning() {
-    this.applyWithWarning = true;
-  }
-
-  reset() {
-    this.isAdmin = false;
-    this.applyWithWarning = false;
-    this.dismissedNoticesEP = false;
-    this.rules = cloneDeep(this.defaultRules);
-  }
-
-  allRulesCount() {
-    return this.rules.length;
-  }
-
-  allRulesName() {
-    return this.rules.map((r) => r.name);
-  }
-
-  allQualityProfile(language: string) {
-    return this.qualityProfile.filter((qp) => qp.language === language);
-  }
-
-  handleGetGacet = (): Promise<{
-    facet: { count: number; val: string }[];
-    response: RawIssuesResponse;
-  }> => {
-    return this.reply({
-      facet: [],
-      response: {
-        components: [],
-        effortTotal: 0,
-        facets: [],
-        issues: [],
-        languages: [],
-        paging: { total: 0, pageIndex: 1, pageSize: 1 },
-      },
-    });
-  };
-
-  handleGetRuleDetails = (parameters: {
-    actives?: boolean;
-    key: string;
-  }): Promise<{ actives?: RuleActivation[]; rule: RuleDetails }> => {
-    const rule = this.rules.find((r) => r.key === parameters.key);
-    if (!rule) {
-      return Promise.reject({
-        errors: [{ msg: `No rule has been found for id ${parameters.key}` }],
-      });
-    }
-    return this.reply({ actives: parameters.actives ? [] : undefined, rule });
-  };
-
-  handleUpdateRule = (data: RulesUpdateRequest): Promise<RuleDetails> => {
-    const rule = this.rules.find((r) => r.key === data.key);
-    if (rule === undefined) {
-      return Promise.reject({
-        errors: [{ msg: `No rule has been found for id ${data.key}` }],
-      });
-    }
-    const template = this.rules.find((r) => r.key === rule.templateKey);
-
-    // Lets not convert the md to html in test.
-    rule.mdDesc = data.markdown_description !== undefined ? data.markdown_description : rule.mdDesc;
-    rule.htmlDesc =
-      data.markdown_description !== undefined ? data.markdown_description : rule.htmlDesc;
-    rule.mdNote = data.markdown_note !== undefined ? data.markdown_note : rule.mdNote;
-    rule.htmlNote = data.markdown_note !== undefined ? data.markdown_note : rule.htmlNote;
-    rule.name = data.name !== undefined ? data.name : rule.name;
-    if (template && data.params) {
-      rule.params = [];
-      data.params.split(';').forEach((param) => {
-        const parts = param.split('=');
-        const paramsDef = template.params?.find((p) => p.key === parts[0]);
-        rule.params?.push({
-          key: parts[0],
-          type: paramsDef?.type || 'STRING',
-          defaultValue: trim(parts[1], '" '),
-          htmlDesc: paramsDef?.htmlDesc,
-        });
-      });
-    }
-
-    rule.remFnBaseEffort =
-      data.remediation_fn_base_effort !== undefined
-        ? data.remediation_fn_base_effort
-        : rule.remFnBaseEffort;
-    rule.remFnType =
-      data.remediation_fn_type !== undefined ? data.remediation_fn_type : rule.remFnType;
-    rule.severity = data.severity !== undefined ? data.severity : rule.severity;
-    rule.status = data.status !== undefined ? data.status : rule.status;
-    rule.tags = data.tags !== undefined ? data.tags.split(';') : rule.tags;
-
-    return this.reply(rule);
-  };
-
-  handleSearchRules = ({
-    facets,
-    languages,
-    p,
-    ps,
-    available_since,
-    rule_key,
-  }: SearchRulesQuery): Promise<SearchRulesResponse> => {
-    const countFacet = (facets || '').split(',').map((facet: keyof Rule) => {
-      const facetCount = countBy(
-        this.rules.map((r) => r[FACET_RULE_MAP[facet] || facet] as string)
-      );
-      return {
-        property: facet,
-        values: Object.keys(facetCount).map((val) => ({ val, count: facetCount[val] })),
-      };
-    });
-    const currentPs = ps || 10;
-    const currentP = p || 1;
-    let filteredRules: Rule[] = [];
-    if (rule_key) {
-      filteredRules = this.getRulesWithoutDetails(this.rules).filter((r) => r.key === rule_key);
-    } else {
-      filteredRules = this.filterFacet({ languages, available_since });
-    }
-    const responseRules = filteredRules.slice((currentP - 1) * currentPs, currentP * currentPs);
-    return this.reply({
-      rules: responseRules,
-      facets: countFacet,
-      paging: mockPaging({
-        total: filteredRules.length,
-        pageIndex: currentP,
-        pageSize: currentPs,
-      }),
-    });
-  };
-
-  handleBulkActivateRules = () => {
-    if (this.applyWithWarning) {
-      return this.reply({
-        succeeded: this.rules.length - 1,
-        failed: 1,
-        errors: [{ msg: 'c rule c:S6069 cannot be activated on cpp profile SonarSource' }],
-      });
-    }
-    return this.reply({
-      succeeded: this.rules.length,
-      failed: 0,
-      errors: [],
-    });
-  };
-
-  handleBulkDeactivateRules = () => {
-    return this.reply({
-      succeeded: this.rules.length,
-      failed: 0,
-    });
-  };
-
-  handleSearchQualityProfiles = ({
-    language,
-  }: SearchQualityProfilesParameters = {}): Promise<SearchQualityProfilesResponse> => {
-    let profiles: Profile[] = this.isAdmin
-      ? this.qualityProfile.map((p) => ({ ...p, actions: { edit: true } }))
-      : this.qualityProfile;
-    if (language) {
-      profiles = profiles.filter((p) => p.language === language);
-    }
-    return this.reply({ profiles });
-  };
-
-  handleGetRulesApp = () => {
-    return this.reply({ canWrite: this.isAdmin, repositories: this.repositories });
-  };
-
-  handleGetCurrentUser = () => {
-    return this.reply(
-      mockCurrentUser({
-        dismissedNotices: {
-          educationPrinciples: this.dismissedNoticesEP,
-        },
-      })
-    );
-  };
-
-  handleDismissNotification = (noticeType: NoticeType) => {
-    if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
-      this.dismissedNoticesEP = true;
-      return this.reply(true);
-    }
-
-    return Promise.reject();
-  };
-
-  reply<T>(response: T): Promise<T> {
-    return Promise.resolve(cloneDeep(response));
-  }
-}
diff --git a/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts
new file mode 100644 (file)
index 0000000..0f6e2e3
--- /dev/null
@@ -0,0 +1,425 @@
+/*
+ * 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 { cloneDeep, countBy, pick, trim } from 'lodash';
+import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
+import {
+  mockCurrentUser,
+  mockPaging,
+  mockQualityProfile,
+  mockRuleDetails,
+  mockRuleRepository,
+} from '../../helpers/testMocks';
+import { RuleRepository, SearchRulesResponse } from '../../types/coding-rules';
+import { RawIssuesResponse } from '../../types/issues';
+import { SearchRulesQuery } from '../../types/rules';
+import { Rule, RuleActivation, RuleDetails, RulesUpdateRequest } from '../../types/types';
+import { NoticeType } from '../../types/users';
+import { getFacet } from '../issues';
+import {
+  Profile,
+  SearchQualityProfilesParameters,
+  SearchQualityProfilesResponse,
+  bulkActivateRules,
+  bulkDeactivateRules,
+  searchQualityProfiles,
+} from '../quality-profiles';
+import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
+import { dismissNotice, getCurrentUser } from '../users';
+
+interface FacetFilter {
+  languages?: string;
+  available_since?: string;
+}
+
+const FACET_RULE_MAP: { [key: string]: keyof Rule } = {
+  languages: 'lang',
+  types: 'type',
+};
+
+export default class CodingRulesServiceMock {
+  defaultRules: RuleDetails[] = [];
+  rules: RuleDetails[] = [];
+  qualityProfile: Profile[] = [];
+  repositories: RuleRepository[] = [];
+  isAdmin = false;
+  applyWithWarning = false;
+  dismissedNoticesEP = false;
+
+  constructor() {
+    this.repositories = [
+      mockRuleRepository({ key: 'repo1' }),
+      mockRuleRepository({ key: 'repo2' }),
+    ];
+    this.qualityProfile = [
+      mockQualityProfile({ key: 'p1', name: 'QP Foo', language: 'java', languageName: 'Java' }),
+      mockQualityProfile({ key: 'p2', name: 'QP Bar', language: 'js' }),
+      mockQualityProfile({ key: 'p3', name: 'QP FooBar', language: 'java', languageName: 'Java' }),
+    ];
+
+    const resourceContent = 'Some link <a href="http://example.com">Awsome Reading</a>';
+    const introTitle = 'Introduction to this rule';
+    const rootCauseContent = 'Root cause';
+    const howToFixContent = 'This is how to fix';
+
+    this.defaultRules = [
+      mockRuleDetails({
+        key: 'rule1',
+        type: 'BUG',
+        lang: 'java',
+        langName: 'Java',
+        name: 'Awsome java rule',
+      }),
+      mockRuleDetails({
+        key: 'rule2',
+        name: 'Hot hotspot',
+        type: 'SECURITY_HOTSPOT',
+        lang: 'js',
+        descriptionSections: [
+          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+          { key: RuleDescriptionSections.ROOT_CAUSE, content: rootCauseContent },
+          { key: RuleDescriptionSections.HOW_TO_FIX, content: howToFixContent },
+          { key: RuleDescriptionSections.ASSESS_THE_PROBLEM, content: 'Assess' },
+          {
+            key: RuleDescriptionSections.RESOURCES,
+            content: resourceContent,
+          },
+        ],
+        langName: 'JavaScript',
+      }),
+      mockRuleDetails({ key: 'rule3', name: 'Unknown rule', lang: 'js', langName: 'JavaScript' }),
+      mockRuleDetails({
+        key: 'rule4',
+        type: 'BUG',
+        lang: 'c',
+        langName: 'C',
+        name: 'Awsome C rule',
+      }),
+      mockRuleDetails({
+        key: 'rule5',
+        type: 'VULNERABILITY',
+        lang: 'py',
+        langName: 'Python',
+        name: 'Awsome Python rule',
+        descriptionSections: [
+          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+          { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
+          {
+            key: RuleDescriptionSections.RESOURCES,
+            content: resourceContent,
+          },
+        ],
+      }),
+      mockRuleDetails({
+        key: 'rule6',
+        type: 'VULNERABILITY',
+        lang: 'py',
+        langName: 'Python',
+        name: 'Bad Python rule',
+        isExternal: true,
+        descriptionSections: undefined,
+      }),
+      mockRuleDetails({
+        key: 'rule7',
+        type: 'VULNERABILITY',
+        lang: 'py',
+        langName: 'Python',
+        name: 'Python rule with context',
+        descriptionSections: [
+          {
+            key: RuleDescriptionSections.INTRODUCTION,
+            content: 'Introduction to this rule with context',
+          },
+          {
+            key: RuleDescriptionSections.HOW_TO_FIX,
+            content: 'This is how to fix for spring',
+            context: { key: 'spring', displayName: 'Spring' },
+          },
+          {
+            key: RuleDescriptionSections.HOW_TO_FIX,
+            content: 'This is how to fix for spring boot',
+            context: { key: 'spring_boot', displayName: 'Spring boot' },
+          },
+          {
+            key: RuleDescriptionSections.RESOURCES,
+            content: resourceContent,
+          },
+        ],
+      }),
+      mockRuleDetails({
+        createdAt: '2022-12-16T17:26:54+0100',
+        key: 'rule8',
+        type: 'VULNERABILITY',
+        lang: 'py',
+        langName: 'Python',
+        name: 'Awesome Python rule with education principles',
+        descriptionSections: [
+          { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+          { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
+          {
+            key: RuleDescriptionSections.RESOURCES,
+            content: resourceContent,
+          },
+        ],
+        educationPrinciples: ['defense_in_depth', 'never_trust_user_input'],
+      }),
+    ];
+
+    (updateRule as jest.Mock).mockImplementation(this.handleUpdateRule);
+    (searchRules as jest.Mock).mockImplementation(this.handleSearchRules);
+    (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
+    (searchQualityProfiles as jest.Mock).mockImplementation(this.handleSearchQualityProfiles);
+    (getRulesApp as jest.Mock).mockImplementation(this.handleGetRulesApp);
+    (bulkActivateRules as jest.Mock).mockImplementation(this.handleBulkActivateRules);
+    (bulkDeactivateRules as jest.Mock).mockImplementation(this.handleBulkDeactivateRules);
+    (getFacet as jest.Mock).mockImplementation(this.handleGetGacet);
+    (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
+    (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
+    this.rules = cloneDeep(this.defaultRules);
+  }
+
+  getRulesWithoutDetails(rules: RuleDetails[]) {
+    return rules.map((r) =>
+      pick(r, [
+        'isTemplate',
+        'key',
+        'lang',
+        'langName',
+        'name',
+        'params',
+        'severity',
+        'status',
+        'sysTags',
+        'tags',
+        'type',
+      ])
+    );
+  }
+
+  filterFacet({ languages, available_since }: FacetFilter) {
+    let filteredRules = this.rules;
+    if (languages) {
+      filteredRules = filteredRules.filter((r) => r.lang && languages.includes(r.lang));
+    }
+    if (available_since) {
+      filteredRules = filteredRules.filter(
+        (r) => r.createdAt && new Date(r.createdAt) > new Date(available_since)
+      );
+    }
+    return this.getRulesWithoutDetails(filteredRules);
+  }
+
+  setIsAdmin() {
+    this.isAdmin = true;
+  }
+
+  activateWithWarning() {
+    this.applyWithWarning = true;
+  }
+
+  reset() {
+    this.isAdmin = false;
+    this.applyWithWarning = false;
+    this.dismissedNoticesEP = false;
+    this.rules = cloneDeep(this.defaultRules);
+  }
+
+  allRulesCount() {
+    return this.rules.length;
+  }
+
+  allRulesName() {
+    return this.rules.map((r) => r.name);
+  }
+
+  allQualityProfile(language: string) {
+    return this.qualityProfile.filter((qp) => qp.language === language);
+  }
+
+  handleGetGacet = (): Promise<{
+    facet: { count: number; val: string }[];
+    response: RawIssuesResponse;
+  }> => {
+    return this.reply({
+      facet: [],
+      response: {
+        components: [],
+        effortTotal: 0,
+        facets: [],
+        issues: [],
+        languages: [],
+        paging: { total: 0, pageIndex: 1, pageSize: 1 },
+      },
+    });
+  };
+
+  handleGetRuleDetails = (parameters: {
+    actives?: boolean;
+    key: string;
+  }): Promise<{ actives?: RuleActivation[]; rule: RuleDetails }> => {
+    const rule = this.rules.find((r) => r.key === parameters.key);
+    if (!rule) {
+      return Promise.reject({
+        errors: [{ msg: `No rule has been found for id ${parameters.key}` }],
+      });
+    }
+    return this.reply({ actives: parameters.actives ? [] : undefined, rule });
+  };
+
+  handleUpdateRule = (data: RulesUpdateRequest): Promise<RuleDetails> => {
+    const rule = this.rules.find((r) => r.key === data.key);
+    if (rule === undefined) {
+      return Promise.reject({
+        errors: [{ msg: `No rule has been found for id ${data.key}` }],
+      });
+    }
+    const template = this.rules.find((r) => r.key === rule.templateKey);
+
+    // Lets not convert the md to html in test.
+    rule.mdDesc = data.markdown_description !== undefined ? data.markdown_description : rule.mdDesc;
+    rule.htmlDesc =
+      data.markdown_description !== undefined ? data.markdown_description : rule.htmlDesc;
+    rule.mdNote = data.markdown_note !== undefined ? data.markdown_note : rule.mdNote;
+    rule.htmlNote = data.markdown_note !== undefined ? data.markdown_note : rule.htmlNote;
+    rule.name = data.name !== undefined ? data.name : rule.name;
+    if (template && data.params) {
+      rule.params = [];
+      data.params.split(';').forEach((param) => {
+        const parts = param.split('=');
+        const paramsDef = template.params?.find((p) => p.key === parts[0]);
+        rule.params?.push({
+          key: parts[0],
+          type: paramsDef?.type || 'STRING',
+          defaultValue: trim(parts[1], '" '),
+          htmlDesc: paramsDef?.htmlDesc,
+        });
+      });
+    }
+
+    rule.remFnBaseEffort =
+      data.remediation_fn_base_effort !== undefined
+        ? data.remediation_fn_base_effort
+        : rule.remFnBaseEffort;
+    rule.remFnType =
+      data.remediation_fn_type !== undefined ? data.remediation_fn_type : rule.remFnType;
+    rule.severity = data.severity !== undefined ? data.severity : rule.severity;
+    rule.status = data.status !== undefined ? data.status : rule.status;
+    rule.tags = data.tags !== undefined ? data.tags.split(';') : rule.tags;
+
+    return this.reply(rule);
+  };
+
+  handleSearchRules = ({
+    facets,
+    languages,
+    p,
+    ps,
+    available_since,
+    rule_key,
+  }: SearchRulesQuery): Promise<SearchRulesResponse> => {
+    const countFacet = (facets || '').split(',').map((facet: keyof Rule) => {
+      const facetCount = countBy(
+        this.rules.map((r) => r[FACET_RULE_MAP[facet] || facet] as string)
+      );
+      return {
+        property: facet,
+        values: Object.keys(facetCount).map((val) => ({ val, count: facetCount[val] })),
+      };
+    });
+    const currentPs = ps || 10;
+    const currentP = p || 1;
+    let filteredRules: Rule[] = [];
+    if (rule_key) {
+      filteredRules = this.getRulesWithoutDetails(this.rules).filter((r) => r.key === rule_key);
+    } else {
+      filteredRules = this.filterFacet({ languages, available_since });
+    }
+    const responseRules = filteredRules.slice((currentP - 1) * currentPs, currentP * currentPs);
+    return this.reply({
+      rules: responseRules,
+      facets: countFacet,
+      paging: mockPaging({
+        total: filteredRules.length,
+        pageIndex: currentP,
+        pageSize: currentPs,
+      }),
+    });
+  };
+
+  handleBulkActivateRules = () => {
+    if (this.applyWithWarning) {
+      return this.reply({
+        succeeded: this.rules.length - 1,
+        failed: 1,
+        errors: [{ msg: 'c rule c:S6069 cannot be activated on cpp profile SonarSource' }],
+      });
+    }
+    return this.reply({
+      succeeded: this.rules.length,
+      failed: 0,
+      errors: [],
+    });
+  };
+
+  handleBulkDeactivateRules = () => {
+    return this.reply({
+      succeeded: this.rules.length,
+      failed: 0,
+    });
+  };
+
+  handleSearchQualityProfiles = ({
+    language,
+  }: SearchQualityProfilesParameters = {}): Promise<SearchQualityProfilesResponse> => {
+    let profiles: Profile[] = this.isAdmin
+      ? this.qualityProfile.map((p) => ({ ...p, actions: { edit: true } }))
+      : this.qualityProfile;
+    if (language) {
+      profiles = profiles.filter((p) => p.language === language);
+    }
+    return this.reply({ profiles });
+  };
+
+  handleGetRulesApp = () => {
+    return this.reply({ canWrite: this.isAdmin, repositories: this.repositories });
+  };
+
+  handleGetCurrentUser = () => {
+    return this.reply(
+      mockCurrentUser({
+        dismissedNotices: {
+          educationPrinciples: this.dismissedNoticesEP,
+        },
+      })
+    );
+  };
+
+  handleDismissNotification = (noticeType: NoticeType) => {
+    if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
+      this.dismissedNoticesEP = true;
+      return this.reply(true);
+    }
+
+    return Promise.reject();
+  };
+
+  reply<T>(response: T): Promise<T> {
+    return Promise.resolve(cloneDeep(response));
+  }
+}
index 849d45ed7a5970625e7fb1a25f503991ff32b8cf..c5085c5ee9694a358ccded7b085bb07ebff24858 100644 (file)
@@ -21,12 +21,13 @@ import { cloneDeep, times } from 'lodash';
 import {
   mockHotspot,
   mockHotspotComment,
+  mockHotspotRule,
   mockRawHotspot,
   mockStandards,
 } from '../../helpers/mocks/security-hotspots';
 import { mockSourceLine } from '../../helpers/mocks/sources';
 import { getStandards } from '../../helpers/security-standard';
-import { mockPaging, mockRuleDetails, mockUser } from '../../helpers/testMocks';
+import { mockPaging, mockUser } from '../../helpers/testMocks';
 import {
   Hotspot,
   HotspotAssignRequest,
@@ -36,7 +37,6 @@ import {
 } from '../../types/security-hotspots';
 import { getSources } from '../components';
 import { getMeasures } from '../measures';
-import { getRuleDetails } from '../rules';
 import {
   assignSecurityHotspot,
   commentSecurityHotspot,
@@ -67,7 +67,6 @@ export default class SecurityHotspotServiceMock {
     jest.mocked(assignSecurityHotspot).mockImplementation(this.handleAssignSecurityHotspot);
     jest.mocked(setSecurityHotspotStatus).mockImplementation(this.handleSetSecurityHotspotStatus);
     jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers);
-    jest.mocked(getRuleDetails).mockResolvedValue({ rule: mockRuleDetails() });
     jest.mocked(getSources).mockResolvedValue(
       times(NUMBER_OF_LINES, (n) =>
         mockSourceLine({
@@ -309,13 +308,23 @@ export default class SecurityHotspotServiceMock {
   reset = () => {
     this.hotspots = [
       mockHotspot({
+        rule: mockHotspotRule({ key: 'rule2' }),
         assignee: 'John Doe',
         key: 'b1-test-1',
         message: "'F' is a magic number.",
       }),
-      mockHotspot({ assignee: 'John Doe', key: 'b1-test-2' }),
-      mockHotspot({ key: 'test-1', status: HotspotStatus.TO_REVIEW }),
       mockHotspot({
+        rule: mockHotspotRule({ key: 'rule2' }),
+        assignee: 'John Doe',
+        key: 'b1-test-2',
+      }),
+      mockHotspot({
+        rule: mockHotspotRule({ key: 'rule2' }),
+        key: 'test-1',
+        status: HotspotStatus.TO_REVIEW,
+      }),
+      mockHotspot({
+        rule: mockHotspotRule({ key: 'rule2' }),
         key: 'test-2',
         status: HotspotStatus.TO_REVIEW,
         message: "'2' is a magic number.",
index f16a0001b65941c2a003e3e34b7ed49bd2619a17..3ec343d5ad310f98bfd027d207072d806e233b8f 100644 (file)
 }
 
 .rule-desc pre,
-.markdown pre,
-.code-difference-scrollable {
+.markdown pre {
   background-color: var(--codeBackground);
   border-radius: 8px;
   border: 1px solid var(--codeBorder);
   overflow-x: auto;
 }
 
-.code-difference-container {
-  display: flex;
-  flex-direction: column;
-  width: fit-content;
-  min-width: 100%;
-}
-
-.code-difference-scrollable .code-added {
-  background-color: var(--codeAdded);
-  padding-left: calc(2 * var(--gridSize));
-  padding-right: calc(2 * var(--gridSize));
-  margin-left: calc(-2 * var(--gridSize));
-  margin-right: calc(-2 * var(--gridSize));
-  border-radius: 0;
-}
-
-.code-difference-scrollable .code-removed {
-  background-color: var(--codeRemoved);
-  padding-left: calc(2 * var(--gridSize));
-  padding-right: calc(2 * var(--gridSize));
-  margin-left: calc(-2 * var(--gridSize));
-  margin-right: calc(-2 * var(--gridSize));
-  border-radius: 0;
-}
-
 .rule-desc code,
 .markdown code,
 code.rule {
index 487606f756035207ea4997ca5b412aac7d20f8aa..5556b269f63ac70abc549257c6502a8e3c79deb1 100644 (file)
@@ -20,7 +20,7 @@
 import { fireEvent, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { byPlaceholderText, byRole } from 'testing-library-selector';
-import CodingRulesMock from '../../../api/mocks/CodingRulesMock';
+import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
 import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderAppRoutes } from '../../../helpers/testReactTestingUtils';
 import { CurrentUser } from '../../../types/users';
@@ -39,12 +39,12 @@ const ui = {
   availableSinceDateField: byPlaceholderText('date'),
 };
 
-let handler: CodingRulesMock;
+let handler: CodingRulesServiceMock;
 
 beforeAll(() => {
   window.scrollTo = jest.fn();
   window.HTMLElement.prototype.scrollIntoView = jest.fn();
-  handler = new CodingRulesMock();
+  handler = new CodingRulesServiceMock();
 });
 
 afterEach(() => handler.reset());
index dc1c9817cf620aad7d41eec52d30723803f41d89..85c9abac74cc62321634d55537e227cdeb4a574e 100644 (file)
   padding-left: 20px;
 }
 
-.rules-context-description ul {
-  padding: 0px;
-}
-
-.rules-context-description h2.rule-contexts-title {
-  border: 0px;
-}
-
 .notice-dot {
   height: var(--gridSize);
   width: var(--gridSize);
index c3ffa80624298939d42bca3f653698cf12a67ee5..3f084399815ad9a7581447a887c89f878a9ffecc 100644 (file)
@@ -126,7 +126,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe
       />
       <LargeCenteredLayout id={MetricKey.security_hotspots}>
         <PageContentFontWrapper>
-          <div className="sw-grid sw-grid-cols-12 sw-w-full">
+          <div className="sw-grid sw-grid-cols-12 sw-w-full sw-body-sm">
             <DeferredSpinner className="sw-mt-3" loading={loading} />
 
             {!loading &&
index 9bfe9fb9085893f11916dd2956baa7a05c5cfa60..2f4accf4d2079c874d3f82f753a432cb7a741481 100644 (file)
@@ -23,6 +23,7 @@ import React from 'react';
 import { Route } from 'react-router-dom';
 import selectEvent from 'react-select-event';
 import { byDisplayValue, byRole, byTestId, byText } from 'testing-library-selector';
+import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
 import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock';
 import { getSecurityHotspots, setSecurityHotspotStatus } from '../../../api/security-hotspots';
 import { searchUsers } from '../../../api/users';
@@ -35,11 +36,14 @@ import SecurityHotspotsApp from '../SecurityHotspotsApp';
 
 jest.mock('../../../api/measures');
 jest.mock('../../../api/security-hotspots');
-jest.mock('../../../api/rules');
 jest.mock('../../../api/components');
 jest.mock('../../../helpers/security-standard');
 jest.mock('../../../api/users');
 
+jest.mock('../../../api/rules');
+jest.mock('../../../api/quality-profiles');
+jest.mock('../../../api/issues');
+
 const ui = {
   inputAssignee: byRole('searchbox', { name: 'hotspots.assignee.select_user' }),
   selectStatusButton: byRole('button', {
@@ -70,16 +74,22 @@ const ui = {
   successGlobalMessage: byRole('status'),
   currentUserSelectionItem: byText('foo'),
   panel: byTestId('security-hotspot-test'),
+  codeTab: byRole('tab', { name: 'hotspots.tabs.code' }),
+  codeContent: byRole('table'),
+  riskTab: byRole('tab', { name: 'hotspots.tabs.risk_description' }),
+  riskContent: byText('Root cause'),
+  vulnerabilityTab: byRole('tab', { name: 'hotspots.tabs.vulnerability_description' }),
+  vulnerabilityContent: byText('Assess'),
+  fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }),
+  fixContent: byText('This is how to fix'),
 };
 
-let handler: SecurityHotspotServiceMock;
-
-beforeEach(() => {
-  handler = new SecurityHotspotServiceMock();
-});
+const hotspotsHandler = new SecurityHotspotServiceMock();
+const rulesHandles = new CodingRulesServiceMock();
 
 afterEach(() => {
-  handler.reset();
+  hotspotsHandler.reset();
+  rulesHandles.reset();
 });
 
 describe('rendering', () => {
@@ -109,170 +119,208 @@ it('should navigate when comming from SonarLint', async () => {
   expect(await ui.hotspotTitle(/'F' is a magic number./).find()).toBeInTheDocument();
 });
 
-it('should be able to self-assign a hotspot', async () => {
-  const user = userEvent.setup();
-  renderSecurityHotspotsApp();
+describe('CRUD', () => {
+  it('should be able to self-assign a hotspot', async () => {
+    const user = userEvent.setup();
+    renderSecurityHotspotsApp();
 
-  expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
+    expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
 
-  await user.click(ui.editAssigneeButton.get());
-  await user.click(ui.currentUserSelectionItem.get());
+    await user.click(ui.editAssigneeButton.get());
+    await user.click(ui.currentUserSelectionItem.get());
 
-  expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
-  expect(ui.activeAssignee.get()).toHaveTextContent('foo');
-});
+    expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
+    expect(ui.activeAssignee.get()).toHaveTextContent('foo');
+  });
 
-it('should be able to search for a user on the assignee', async () => {
-  const user = userEvent.setup();
-  renderSecurityHotspotsApp();
+  it('should be able to search for a user on the assignee', async () => {
+    const user = userEvent.setup();
+    renderSecurityHotspotsApp();
 
-  await user.click(await ui.editAssigneeButton.find());
-  await user.click(ui.inputAssignee.get());
+    await user.click(await ui.editAssigneeButton.find());
+    await user.click(ui.inputAssignee.get());
 
-  await user.keyboard('User');
+    await user.keyboard('User');
 
-  expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
-  await user.keyboard('{ArrowDown}{Enter}');
-  expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
-});
+    expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
+    await user.keyboard('{ArrowDown}{Enter}');
+    expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
+  });
 
-it('should be able to filter the hotspot list', async () => {
-  const user = userEvent.setup();
-  renderSecurityHotspotsApp();
+  it('should be able to change the status of a hotspot', async () => {
+    const user = userEvent.setup();
+    const comment = 'COMMENT-TEXT';
 
-  expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
+    renderSecurityHotspotsApp();
 
-  await user.click(ui.filterAssigneeToMe.get());
-  expect(ui.noHotspotForFilter.get()).toBeInTheDocument();
-  await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']);
+    expect(await ui.selectStatus.find()).toBeInTheDocument();
 
-  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
-    inNewCodePeriod: false,
-    onlyMine: true,
-    p: 1,
-    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
-    ps: 500,
-    resolution: undefined,
-    status: 'TO_REVIEW',
-  });
+    await user.click(ui.selectStatus.get());
+    await user.click(ui.toReviewStatus.get());
 
-  await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']);
+    await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
+    await user.keyboard(comment);
 
-  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
-    inNewCodePeriod: true,
-    onlyMine: true,
-    p: 1,
-    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
-    ps: 500,
-    resolution: undefined,
-    status: 'TO_REVIEW',
-  });
+    await act(async () => {
+      await user.click(ui.changeStatus.get());
+    });
 
-  await user.click(ui.filterSeeAll.get());
+    expect(setSecurityHotspotStatus).toHaveBeenLastCalledWith('test-1', {
+      comment: 'COMMENT-TEXT',
+      resolution: undefined,
+      status: 'TO_REVIEW',
+    });
 
-  expect(ui.hotpostListTitle.get()).toBeInTheDocument();
-});
+    expect(ui.hotspotStatus.get()).toBeInTheDocument();
+  });
 
-it('should be able to navigate the hotspot list with keyboard', async () => {
-  const user = userEvent.setup();
-  renderSecurityHotspotsApp();
+  it('should not be able to change the status if does not have edit permissions', async () => {
+    hotspotsHandler.setHotspotChangeStatusPermission(false);
+    renderSecurityHotspotsApp();
+    expect(await ui.selectStatus.find()).toBeDisabled();
+  });
 
-  await user.keyboard('{ArrowDown}');
-  expect(await ui.hotspotTitle(/'2' is a magic number./).find()).toBeInTheDocument();
-  await user.keyboard('{ArrowUp}');
-  expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
-});
+  it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
+    const user = userEvent.setup();
+    renderSecurityHotspotsApp();
 
-it('should be able to change the status of a hotspot', async () => {
-  const user = userEvent.setup();
-  const comment = 'COMMENT-TEXT';
+    await user.click(await ui.selectStatusButton.find());
+    const comment = 'This is a comment';
 
-  renderSecurityHotspotsApp();
+    const commentSection = within(ui.panel.get()).getByRole('textbox');
+    await user.click(commentSection);
+    await user.keyboard(comment);
 
-  expect(await ui.selectStatus.find()).toBeInTheDocument();
+    // Close the panel
+    await act(async () => {
+      await user.keyboard('{Escape}');
+    });
 
-  await user.click(ui.selectStatus.get());
-  await user.click(ui.toReviewStatus.get());
+    // Check panel is closed
+    expect(ui.panel.query()).not.toBeInTheDocument();
 
-  await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
-  await user.keyboard(comment);
+    await user.click(ui.selectStatusButton.get());
 
-  await act(async () => {
-    await user.click(ui.changeStatus.get());
+    expect(await screen.findByText(comment)).toBeInTheDocument();
   });
 
-  expect(setSecurityHotspotStatus).toHaveBeenLastCalledWith('test-1', {
-    comment: 'COMMENT-TEXT',
-    resolution: undefined,
-    status: 'TO_REVIEW',
-  });
+  it('should be able to add, edit and remove own comments', async () => {
+    const uiComment = {
+      saveButton: byRole('button', { name: 'save' }),
+      deleteButton: byRole('button', { name: 'delete' }),
+    };
+    const user = userEvent.setup();
+    const comment = 'This is a comment from john doe';
+    renderSecurityHotspotsApp();
 
-  expect(ui.hotspotStatus.get()).toBeInTheDocument();
-});
+    const commentSection = await ui.hotspotCommentBox.find();
+    const submitButton = ui.commentSubmitButton.get();
 
-it('should not be able to change the status if does not have edit permissions', async () => {
-  handler.setHotspotChangeStatusPermission(false);
-  renderSecurityHotspotsApp();
-  expect(await ui.selectStatus.find()).toBeDisabled();
+    // Add a new comment
+    await user.click(commentSection);
+    await user.keyboard(comment);
+    await user.click(submitButton);
+
+    expect(await screen.findByText(comment)).toBeInTheDocument();
+
+    // Edit the comment
+    await user.click(ui.commentEditButton.get());
+    await user.click(ui.textboxWithText(comment).get());
+    await user.keyboard(' test');
+    await user.click(uiComment.saveButton.get());
+
+    expect(await byText(`${comment} test`).find()).toBeInTheDocument();
+
+    // Delete the comment
+    await user.click(ui.commentDeleteButton.get());
+    await user.click(uiComment.deleteButton.get());
+
+    expect(screen.queryByText(`${comment} test`)).not.toBeInTheDocument();
+  });
 });
 
-it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
-  const user = userEvent.setup();
-  renderSecurityHotspotsApp();
+describe('navigation', () => {
+  it('should correctly handle tabs', async () => {
+    const user = userEvent.setup();
+    renderSecurityHotspotsApp();
+
+    await user.click(await ui.riskTab.find());
+    expect(ui.riskContent.get()).toBeInTheDocument();
 
-  await user.click(await ui.selectStatusButton.find());
+    await user.click(ui.vulnerabilityTab.get());
+    expect(ui.vulnerabilityContent.get()).toBeInTheDocument();
 
-  const comment = 'This is a comment';
+    await user.click(ui.fixTab.get());
+    expect(ui.fixContent.get()).toBeInTheDocument();
 
-  const commentSection = within(ui.panel.get()).getByRole('textbox');
-  await user.click(commentSection);
-  await user.keyboard(comment);
+    await user.click(ui.codeTab.get());
+    expect(ui.codeContent.get()).toHaveClass('source-table');
+  });
+
+  it('should be able to navigate the hotspot list with keyboard', async () => {
+    const user = userEvent.setup();
+    renderSecurityHotspotsApp();
 
-  // Close the panel
-  await act(async () => {
-    await user.keyboard('{Escape}');
+    await user.keyboard('{ArrowDown}');
+    expect(await ui.hotspotTitle(/'2' is a magic number./).find()).toBeInTheDocument();
+    await user.keyboard('{ArrowUp}');
+    expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
   });
 
-  // Check panel is closed
-  expect(ui.panel.query()).not.toBeInTheDocument();
+  it('should navigate when coming from SonarLint', async () => {
+    // On main branch
+    const rtl = renderSecurityHotspotsApp(
+      'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-1'
+    );
+
+    expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
 
-  await user.click(ui.selectStatusButton.get());
+    // On specific branch
+    rtl.unmount();
+    renderSecurityHotspotsApp(
+      'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=b1-test-1&branch=b1',
+      { branchLike: mockBranch({ name: 'b1' }) }
+    );
 
-  expect(await screen.findByText(comment)).toBeInTheDocument();
+    expect(await ui.hotspotTitle(/'F' is a magic number./).find()).toBeInTheDocument();
+  });
 });
 
-it('should be able to add, edit and remove own comments', async () => {
-  const uiComment = {
-    saveButton: byRole('button', { name: 'save' }),
-    deleteButton: byRole('button', { name: 'delete' }),
-  };
+it('should be able to filter the hotspot list', async () => {
   const user = userEvent.setup();
-  const comment = 'This is a comment from john doe';
   renderSecurityHotspotsApp();
 
-  const commentSection = await ui.hotspotCommentBox.find();
-  const submitButton = ui.commentSubmitButton.get();
+  expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
 
-  // Add a new comment
-  await user.click(commentSection);
-  await user.keyboard(comment);
-  await user.click(submitButton);
+  await user.click(ui.filterAssigneeToMe.get());
+  expect(ui.noHotspotForFilter.get()).toBeInTheDocument();
+  await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']);
 
-  expect(await screen.findByText(comment)).toBeInTheDocument();
+  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
+    inNewCodePeriod: false,
+    onlyMine: true,
+    p: 1,
+    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+    ps: 500,
+    resolution: undefined,
+    status: 'TO_REVIEW',
+  });
 
-  // Edit the comment
-  await user.click(ui.commentEditButton.get());
-  await user.click(ui.textboxWithText(comment).get());
-  await user.keyboard(' test');
-  await user.click(uiComment.saveButton.get());
+  await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']);
 
-  expect(await byText(`${comment} test`).find()).toBeInTheDocument();
+  expect(getSecurityHotspots).toHaveBeenLastCalledWith({
+    inNewCodePeriod: true,
+    onlyMine: true,
+    p: 1,
+    projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+    ps: 500,
+    resolution: undefined,
+    status: 'TO_REVIEW',
+  });
 
-  // Delete the comment
-  await user.click(ui.commentDeleteButton.get());
-  await user.click(uiComment.deleteButton.get());
+  await user.click(ui.filterSeeAll.get());
 
-  expect(screen.queryByText(`${comment} test`)).not.toBeInTheDocument();
+  expect(ui.hotpostListTitle.get()).toBeInTheDocument();
 });
 
 function renderSecurityHotspotsApp(
index 8ba847f92e13eb3c16bf263651530ef646daaab9..0f2807eb5370d7e05f7a2b1807eb922efa432c43 100644 (file)
@@ -17,9 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { ToggleButton, getTabId, getTabPanelId } from 'design-system';
 import { groupBy } from 'lodash';
 import * as React from 'react';
-import BoxedTabs, { getTabId, getTabPanelId } from '../../../components/controls/BoxedTabs';
 import RuleDescription from '../../../components/rules/RuleDescription';
 import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
 import { KeyboardKeys } from '../../../helpers/keycodes';
@@ -40,8 +40,8 @@ interface State {
 }
 
 interface Tab {
-  key: TabKeys;
-  label: React.ReactNode;
+  value: TabKeys;
+  label: string;
   content: React.ReactNode;
 }
 
@@ -50,6 +50,7 @@ export enum TabKeys {
   RiskDescription = 'risk',
   VulnerabilityDescription = 'vulnerability',
   FixRecommendation = 'fix',
+  Activity = 'activity',
 }
 
 export default class HotspotViewerTabs extends React.PureComponent<Props, State> {
@@ -114,8 +115,10 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
 
   handleSelectTabs = (tabKey: TabKeys) => {
     const { tabs } = this.state;
-    const currentTab = tabs.find((tab) => tab.key === tabKey)!;
-    this.setState({ currentTab });
+    const currentTab = tabs.find((tab) => tab.value === tabKey);
+    if (currentTab) {
+      this.setState({ currentTab });
+    }
   };
 
   computeTabs() {
@@ -127,40 +130,32 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
 
     return [
       {
-        key: TabKeys.Code,
+        value: TabKeys.Code,
         label: translate('hotspots.tabs.code'),
-        content: <div className="padded">{codeTabContent}</div>,
+        content: codeTabContent,
       },
       {
-        key: TabKeys.RiskDescription,
+        value: TabKeys.RiskDescription,
         label: translate('hotspots.tabs.risk_description'),
         content: rootCauseDescriptionSections && (
-          <RuleDescription
-            className="big-padded"
-            sections={rootCauseDescriptionSections}
-            isDefault={true}
-          />
+          <RuleDescription sections={rootCauseDescriptionSections} />
         ),
       },
       {
-        key: TabKeys.VulnerabilityDescription,
+        value: TabKeys.VulnerabilityDescription,
         label: translate('hotspots.tabs.vulnerability_description'),
         content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
           <RuleDescription
-            className="big-padded"
             sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
-            isDefault={true}
           />
         ),
       },
       {
-        key: TabKeys.FixRecommendation,
+        value: TabKeys.FixRecommendation,
         label: translate('hotspots.tabs.fix_recommendations'),
         content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
           <RuleDescription
-            className="big-padded"
             sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
-            isDefault={true}
           />
         ),
       },
@@ -169,7 +164,7 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
 
   selectNeighboringTab(shift: number) {
     this.setState(({ tabs, currentTab }) => {
-      const index = currentTab && tabs.findIndex((tab) => tab.key === currentTab.key);
+      const index = currentTab && tabs.findIndex((tab) => tab.value === currentTab.value);
 
       if (index !== undefined && index > -1) {
         const newIndex = Math.max(0, Math.min(tabs.length - 1, index + shift));
@@ -186,12 +181,17 @@ export default class HotspotViewerTabs extends React.PureComponent<Props, State>
     const { tabs, currentTab } = this.state;
     return (
       <>
-        <BoxedTabs onSelect={this.handleSelectTabs} selected={currentTab.key} tabs={tabs} />
+        <ToggleButton
+          role="tablist"
+          value={currentTab.value}
+          options={tabs}
+          onChange={this.handleSelectTabs}
+        />
         <div
-          className="bordered huge-spacer-bottom"
+          aria-labelledby={getTabId(currentTab.value)}
+          className="sw-mt-6"
+          id={getTabPanelId(currentTab.value)}
           role="tabpanel"
-          aria-labelledby={getTabId(currentTab.key)}
-          id={getTabPanelId(currentTab.key)}
         >
           {currentTab.content}
         </div>
index 4baecc33dcb2bc4487561a2bf650df8e6e468824..1b6763dbe69dce9b0df657abc778d300e2747d4c 100644 (file)
@@ -17,7 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import classNames from 'classnames';
+import styled from '@emotion/styled';
+import { HtmlFormatter, themeBorder, themeColor } from 'design-system';
 import * as React from 'react';
 import { RuleDescriptionSection } from '../../apps/coding-rules/rule';
 import applyCodeDifferences from '../../helpers/code-difference';
@@ -30,7 +31,6 @@ import OtherContextOption from './OtherContextOption';
 const OTHERS_KEY = 'others';
 
 interface Props {
-  isDefault?: boolean;
   sections: RuleDescriptionSection[];
   defaultContextKey?: string;
   className?: string;
@@ -110,7 +110,7 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { className, sections, isDefault } = this.props;
+    const { className, sections } = this.props;
     const { contexts, defaultContext, selectedContext } = this.state;
 
     const options = contexts.map((ctxt) => ({
@@ -120,62 +120,54 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
 
     if (contexts.length > 0 && selectedContext) {
       return (
-        <div
-          className={classNames(className, {
-            markdown: isDefault,
-            'rule-desc': !isDefault,
-          })}
+        <StyledHtmlFormatter
+          className={className}
           ref={(node) => {
             applyCodeDifferences(node);
           }}
         >
-          <div className="rules-context-description">
-            <h2 className="rule-contexts-title">
-              {translate('coding_rules.description_context.title')}
-            </h2>
-            {defaultContext && (
-              <Alert variant="info" display="inline" className="big-spacer-bottom">
+          <h2 className="sw-body-sm-highlight sw-mb-4">
+            {translate('coding_rules.description_context.title')}
+          </h2>
+          {defaultContext && (
+            <Alert variant="info" display="inline" className="big-spacer-bottom">
+              {translateWithParameters(
+                'coding_rules.description_context.default_information',
+                defaultContext.displayName
+              )}
+            </Alert>
+          )}
+          <div className="big-spacer-bottom">
+            <ButtonToggle
+              label={translate('coding_rules.description_context.title')}
+              onCheck={this.handleToggleContext}
+              options={options}
+              value={selectedContext.displayName}
+            />
+            {selectedContext.key !== OTHERS_KEY && (
+              <h2>
                 {translateWithParameters(
-                  'coding_rules.description_context.default_information',
-                  defaultContext.displayName
+                  'coding_rules.description_context.sub_title',
+                  selectedContext.displayName
                 )}
-              </Alert>
-            )}
-            <div className="big-spacer-bottom">
-              <ButtonToggle
-                label={translate('coding_rules.description_context.title')}
-                onCheck={this.handleToggleContext}
-                options={options}
-                value={selectedContext.displayName}
-              />
-              {selectedContext.key !== OTHERS_KEY && (
-                <h2>
-                  {translateWithParameters(
-                    'coding_rules.description_context.sub_title',
-                    selectedContext.displayName
-                  )}
-                </h2>
-              )}
-            </div>
-            {selectedContext.key === OTHERS_KEY ? (
-              <OtherContextOption />
-            ) : (
-              <div
-                /* eslint-disable-next-line react/no-danger */
-                dangerouslySetInnerHTML={{ __html: sanitizeString(selectedContext.content) }}
-              />
+              </h2>
             )}
           </div>
-        </div>
+          {selectedContext.key === OTHERS_KEY ? (
+            <OtherContextOption />
+          ) : (
+            <div
+              /* eslint-disable-next-line react/no-danger */
+              dangerouslySetInnerHTML={{ __html: sanitizeString(selectedContext.content) }}
+            />
+          )}
+        </StyledHtmlFormatter>
       );
     }
 
     return (
-      <div
-        className={classNames(className, {
-          markdown: isDefault,
-          'rule-desc': !isDefault,
-        })}
+      <StyledHtmlFormatter
+        className={className}
         ref={(node) => {
           applyCodeDifferences(node);
         }}
@@ -187,3 +179,37 @@ export default class RuleDescription extends React.PureComponent<Props, State> {
     );
   }
 }
+
+const StyledHtmlFormatter = styled(HtmlFormatter)`
+  .code-difference-container {
+    display: flex;
+    flex-direction: column;
+    width: fit-content;
+    min-width: 100%;
+  }
+
+  .code-difference-scrollable {
+    background-color: ${themeColor('codeSnippetBackground')};
+    border: ${themeBorder('default', 'codeSnippetBorder')};
+    border-radius: 0.5rem;
+    padding: 1.5rem;
+    overflow-x: auto;
+  }
+
+  .code-difference-scrollable .code-added,
+  .code-difference-scrollable .code-removed {
+    padding-left: 1.5rem;
+    margin-left: -1.5rem;
+    padding-right: 1.5rem;
+    margin-right: -1.5rem;
+    border-radius: 0;
+  }
+
+  .code-difference-scrollable .code-added {
+    background-color: ${themeColor('codeLineCoveredUnderline')};
+  }
+
+  .code-difference-scrollable .code-removed {
+    background-color: ${themeColor('codeLineUncoveredUnderline')};
+  }
+`;
index bd0fbf2952e38387e6d89dfc7c090401e5b0a169..259571ca845b02797f9340e4741956f5b791308a 100644 (file)
@@ -196,7 +196,6 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State
               descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
               descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]
             }
-            isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined}
             defaultContextKey={ruleDescriptionContextKey}
           />
         ),