From faf2c7ae1f4a3f537e0f8493ad82a816c21d4568 Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Mon, 15 May 2023 14:26:02 +0200 Subject: [PATCH] SONAR-19236 Move Security Hotspots content to new tabs --- .../src/components/HtmlFormatter.tsx | 142 +++++++++ .../src/components/ToggleButton.tsx | 11 +- .../__tests__/ToggleButton-test.tsx | 20 +- .../design-system/src/components/index.ts | 1 + .../src/helpers/__tests__/tabs-test.ts | 25 ++ .../design-system/src/helpers/index.ts | 1 + .../design-system/src/helpers/tabs.ts | 27 ++ .../design-system/src/theme/light.ts | 2 + ...RulesMock.ts => CodingRulesServiceMock.ts} | 7 +- .../api/mocks/SecurityHotspotServiceMock.ts | 19 +- .../src/main/js/app/styles/style.css | 28 +- .../coding-rules/__tests__/CodingRules-it.ts | 6 +- .../src/main/js/apps/coding-rules/styles.css | 8 - .../SecurityHotspotsAppRenderer.tsx | 2 +- .../__tests__/SecurityHotspotsApp-it.tsx | 294 ++++++++++-------- .../components/HotspotViewerTabs.tsx | 48 +-- .../js/components/rules/RuleDescription.tsx | 120 ++++--- .../js/components/rules/RuleTabViewer.tsx | 1 - 18 files changed, 515 insertions(+), 247 deletions(-) create mode 100644 server/sonar-web/design-system/src/components/HtmlFormatter.tsx create mode 100644 server/sonar-web/design-system/src/helpers/__tests__/tabs-test.ts create mode 100644 server/sonar-web/design-system/src/helpers/tabs.ts rename server/sonar-web/src/main/js/api/mocks/{CodingRulesMock.ts => CodingRulesServiceMock.ts} (98%) 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 index 00000000000..2142e789a9e --- /dev/null +++ b/server/sonar-web/design-system/src/components/HtmlFormatter.tsx @@ -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')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/ToggleButton.tsx b/server/sonar-web/design-system/src/components/ToggleButton.tsx index 4ae54df92cb..6291bdd2102 100644 --- a/server/sonar-web/design-system/src/components/ToggleButton.tsx +++ b/server/sonar-web/design-system/src/components/ToggleButton.tsx @@ -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 { label?: string; onChange: (value: T) => void; options: Array>; + role?: 'radiogroup' | 'tablist'; value?: T; } export function ToggleButton(props: ButtonToggleProps) { - const { disabled = false, label, options, value } = props; + const { disabled = false, label, options, value, role = 'radiogroup' } = props; + const isRadioGroup = role === 'radiogroup'; return ( - + {options.map((option) => ( { if (option.value !== value) { props.onChange(option.value); } }} - role="radio" + role={isRadioGroup ? 'radio' : 'tab'} selected={option.value === value} > {option.label} diff --git a/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx b/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx index c691211c7ea..b34ae78d4b8 100644 --- a/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/ToggleButton-test.tsx @@ -19,21 +19,19 @@ */ 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> = [ { 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> = [ + { 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> = {}) { return render(); } diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 51a75a9969c..edff1756a63 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -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 index 00000000000..0512fdcac38 --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/__tests__/tabs-test.ts @@ -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'); +}); diff --git a/server/sonar-web/design-system/src/helpers/index.ts b/server/sonar-web/design-system/src/helpers/index.ts index 5e62e8b766f..541abac6a36 100644 --- a/server/sonar-web/design-system/src/helpers/index.ts +++ b/server/sonar-web/design-system/src/helpers/index.ts @@ -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 index 00000000000..1ef3fccea40 --- /dev/null +++ b/server/sonar-web/design-system/src/helpers/tabs.ts @@ -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}`; +} diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 4854e46adb7..ea90096897c 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -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/CodingRulesServiceMock.ts similarity index 98% rename from server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts rename to server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts index 312b864de7a..0f6e2e397f5 100644 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts @@ -52,7 +52,8 @@ const FACET_RULE_MAP: { [key: string]: keyof Rule } = { languages: 'lang', types: 'type', }; -export default class CodingRulesMock { + +export default class CodingRulesServiceMock { defaultRules: RuleDetails[] = []; rules: RuleDetails[] = []; qualityProfile: Profile[] = []; @@ -74,7 +75,8 @@ export default class CodingRulesMock { const resourceContent = 'Some link Awsome Reading'; const introTitle = 'Introduction to this rule'; - const rootCauseContent = 'This how to fix'; + const rootCauseContent = 'Root cause'; + const howToFixContent = 'This is how to fix'; this.defaultRules = [ mockRuleDetails({ @@ -92,6 +94,7 @@ export default class CodingRulesMock { 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, diff --git a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts index 849d45ed7a5..c5085c5ee96 100644 --- a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts @@ -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.", diff --git a/server/sonar-web/src/main/js/app/styles/style.css b/server/sonar-web/src/main/js/app/styles/style.css index f16a0001b65..3ec343d5ad3 100644 --- a/server/sonar-web/src/main/js/app/styles/style.css +++ b/server/sonar-web/src/main/js/app/styles/style.css @@ -150,8 +150,7 @@ } .rule-desc pre, -.markdown pre, -.code-difference-scrollable { +.markdown pre { background-color: var(--codeBackground); border-radius: 8px; border: 1px solid var(--codeBorder); @@ -161,31 +160,6 @@ 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 { diff --git a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts index 487606f7560..5556b269f63 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts +++ b/server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts @@ -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()); diff --git a/server/sonar-web/src/main/js/apps/coding-rules/styles.css b/server/sonar-web/src/main/js/apps/coding-rules/styles.css index dc1c9817cf6..85c9abac74c 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/styles.css +++ b/server/sonar-web/src/main/js/apps/coding-rules/styles.css @@ -289,14 +289,6 @@ 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); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx index c3ffa806242..3f084399815 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsAppRenderer.tsx @@ -126,7 +126,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe /> -
+
{!loading && diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx index 9bfe9fb9085..2f4accf4d20 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx @@ -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( diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx index 8ba847f92e1..0f2807eb537 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx @@ -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 { @@ -114,8 +115,10 @@ export default class HotspotViewerTabs extends React.PureComponent 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 return [ { - key: TabKeys.Code, + value: TabKeys.Code, label: translate('hotspots.tabs.code'), - content:
{codeTabContent}
, + content: codeTabContent, }, { - key: TabKeys.RiskDescription, + value: TabKeys.RiskDescription, label: translate('hotspots.tabs.risk_description'), content: rootCauseDescriptionSections && ( - + ), }, { - key: TabKeys.VulnerabilityDescription, + value: TabKeys.VulnerabilityDescription, label: translate('hotspots.tabs.vulnerability_description'), content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( ), }, { - key: TabKeys.FixRecommendation, + value: TabKeys.FixRecommendation, label: translate('hotspots.tabs.fix_recommendations'), content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( ), }, @@ -169,7 +164,7 @@ export default class HotspotViewerTabs extends React.PureComponent 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 const { tabs, currentTab } = this.state; return ( <> - +
{currentTab.content}
diff --git a/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx b/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx index 4baecc33dcb..1b6763dbe69 100644 --- a/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx +++ b/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx @@ -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 { }; 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 { if (contexts.length > 0 && selectedContext) { return ( -
{ applyCodeDifferences(node); }} > -
-

- {translate('coding_rules.description_context.title')} -

- {defaultContext && ( - +

+ {translate('coding_rules.description_context.title')} +

+ {defaultContext && ( + + {translateWithParameters( + 'coding_rules.description_context.default_information', + defaultContext.displayName + )} + + )} +
+ + {selectedContext.key !== OTHERS_KEY && ( +

{translateWithParameters( - 'coding_rules.description_context.default_information', - defaultContext.displayName + 'coding_rules.description_context.sub_title', + selectedContext.displayName )} - - )} -
- - {selectedContext.key !== OTHERS_KEY && ( -

- {translateWithParameters( - 'coding_rules.description_context.sub_title', - selectedContext.displayName - )} -

- )} -
- {selectedContext.key === OTHERS_KEY ? ( - - ) : ( -
+

)}
-
+ {selectedContext.key === OTHERS_KEY ? ( + + ) : ( +
+ )} + ); } return ( -
{ applyCodeDifferences(node); }} @@ -187,3 +179,37 @@ export default class RuleDescription extends React.PureComponent { ); } } + +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')}; + } +`; diff --git a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx index bd0fbf2952e..259571ca845 100644 --- a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx @@ -196,7 +196,6 @@ export class RuleTabViewer extends React.PureComponent ), -- 2.39.5