From: Wouter Admiraal Date: Mon, 15 May 2023 12:26:02 +0000 (+0200) Subject: SONAR-19236 Move Security Hotspots content to new tabs X-Git-Tag: 10.1.0.73491~231 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=faf2c7ae1f4a3f537e0f8493ad82a816c21d4568;p=sonarqube.git SONAR-19236 Move Security Hotspots content to new tabs --- 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/CodingRulesMock.ts deleted file mode 100644 index 312b864de7a..00000000000 --- a/server/sonar-web/src/main/js/api/mocks/CodingRulesMock.ts +++ /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 Awsome Reading'; - 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 => { - 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 => { - 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 => { - 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(response: T): Promise { - 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 index 00000000000..0f6e2e397f5 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/CodingRulesServiceMock.ts @@ -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 Awsome Reading'; + 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 => { + 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 => { + 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 => { + 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(response: T): Promise { + return Promise.resolve(cloneDeep(response)); + } +} 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 ),