From: Revanshu Paliwal Date: Thu, 11 May 2023 12:41:56 +0000 (+0200) Subject: SONAR-19174 Migrating code viewer to MIUI X-Git-Tag: 10.1.0.73491~176 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=a2597f5b3d3bb5b4e476bb3c4bf92a1b54200bc3;p=sonarqube.git SONAR-19174 Migrating code viewer to MIUI --- diff --git a/server/sonar-web/design-system/src/components/DatePicker.tsx b/server/sonar-web/design-system/src/components/DatePicker.tsx index 601eae00971..a09e488afc5 100644 --- a/server/sonar-web/design-system/src/components/DatePicker.tsx +++ b/server/sonar-web/design-system/src/components/DatePicker.tsx @@ -47,7 +47,7 @@ import { FocusOutHandler } from './FocusOutHandler'; import { InputField } from './InputField'; import { InputSelect } from './InputSelect'; import { InteractiveIcon } from './InteractiveIcon'; -import OutsideClickHandler from './OutsideClickHandler'; +import { OutsideClickHandler } from './OutsideClickHandler'; import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons'; import { CloseIcon } from './icons/CloseIcon'; import { Popup } from './popups'; diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx index b9e430d7623..d822cf68437 100644 --- a/server/sonar-web/design-system/src/components/DropdownMenu.tsx +++ b/server/sonar-web/design-system/src/components/DropdownMenu.tsx @@ -23,7 +23,6 @@ import classNames from 'classnames'; import React from 'react'; import tw from 'twin.macro'; import { INPUT_SIZES } from '../helpers/constants'; -import { translate } from '../helpers/l10n'; import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { InputSizeKeys, ThemedProps } from '../types/theme'; import { Checkbox } from './Checkbox'; @@ -196,14 +195,15 @@ interface ItemCopyProps { children?: React.ReactNode; className?: string; copyValue: string; + tooltipOverlay: React.ReactNode; } export function ItemCopy(props: ItemCopyProps) { - const { children, className, copyValue } = props; + const { children, className, copyValue, tooltipOverlay } = props; return ( {({ setCopyButton, copySuccess }) => ( - +
  • void; + selected: boolean; + text?: number | string; +} + +function IssueLocationMarkerFunc( + { className, onClick, text, selected }: Props, + ref: LegacyRef +) { + return ( + + {isDefined(text) ? text : } + + ); +} + +export const IssueLocationMarker = forwardRef(IssueLocationMarkerFunc); + +export const Marker = styled.span` + ${tw`sw-flex sw-grow-0 sw-items-center sw-justify-center`} + ${tw`sw-body-sm-highlight`} + ${tw`sw-rounded-1/2`} + + height: 1.125rem; + color: ${themeContrast('codeLineLocationMarker')}; + background-color: ${themeColor('codeLineLocationMarker')}; + + &.selected, + &:hover { + background-color: ${themeColor('codeLineLocationMarkerSelected')}; + } + + &:not(.concealed) { + ${tw`sw-px-1`} + ${tw`sw-self-start`} + } +`; diff --git a/server/sonar-web/design-system/src/components/Link.tsx b/server/sonar-web/design-system/src/components/Link.tsx index 8f1bb78e43f..c1176015223 100644 --- a/server/sonar-web/design-system/src/components/Link.tsx +++ b/server/sonar-web/design-system/src/components/Link.tsx @@ -207,3 +207,10 @@ export const StandoutLink = styled(StyledBaseLink)` } `; StandoutLink.displayName = 'StandoutLink'; + +export const IssueIndicatorLink = styled(BaseLink)` + color: ${themeColor('codeLineMeta')}; + text-decoration: none; + + ${tw`sw-whitespace-nowrap`} +`; diff --git a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx index fb33549c1d1..b13afd47c9f 100644 --- a/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx +++ b/server/sonar-web/design-system/src/components/OutsideClickHandler.tsx @@ -27,7 +27,7 @@ interface Props { onClickOutside: () => void; } -export default class OutsideClickHandler extends React.Component { +export class OutsideClickHandler extends React.Component { mounted = false; componentDidMount() { diff --git a/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx b/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx new file mode 100644 index 00000000000..8e8d7b57048 --- /dev/null +++ b/server/sonar-web/design-system/src/components/SonarCodeColorizer.tsx @@ -0,0 +1,87 @@ +/* + * 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 { themeColor } from '../helpers/theme'; + +export const SonarCodeColorizer = styled.div` + & pre { + ${tw`sw-code`} + + color: ${themeColor('codeSyntaxBody')}; + } + + /* for example java annotations */ + & .a { + color: ${themeColor('codeSyntaxAnnotations')}; + } + + /* constants */ + & .c { + ${tw`sw-code-highlight`} + + color: ${themeColor('codeSyntaxConstants')}; + } + + /* classic comment */ + & .cd { + ${tw`sw-code-comment`} + + color: ${themeColor('codeSyntaxComments')}; + } + + /* javadoc */ + & .j { + ${tw`sw-code-comment`} + + color: ${themeColor('codeSyntaxComments')}; + } + + /* C++ doc */ + & .cppd { + ${tw`sw-code-comment`} + + color: ${themeColor('codeSyntaxComments')}; + } + + /* keyword */ + & .k { + ${tw`sw-code-highlight`} + + color: ${themeColor('codeSyntaxKeyword')}; + } + + /* string */ + & .s { + color: ${themeColor('codeSyntaxString')}; + } + + /* keyword light */ + & .h { + color: ${themeColor('codeSyntaxKeywordLight')}; + } + + /* preprocessing directive */ + & .p { + color: ${themeColor('codeSyntaxPreprocessingDirective')}; + } +`; +SonarCodeColorizer.displayName = 'SonarCodeColorizer'; diff --git a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx index 1e68ae1d22e..41da9ead332 100644 --- a/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/DropdownMenu-test.tsx @@ -32,8 +32,8 @@ import { ItemNavLink, ItemRadioButton, } from '../DropdownMenu'; -import { MenuIcon } from '../icons/MenuIcon'; import Tooltip from '../Tooltip'; +import { MenuIcon } from '../icons/MenuIcon'; beforeEach(() => { jest.useFakeTimers(); @@ -88,7 +88,9 @@ function renderDropdownMenu() { Button DangerButton - Copy + + Copy + Checkbox item diff --git a/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx new file mode 100644 index 00000000000..5fa1bc69eb4 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/LineCoverage-test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { LineCoverage } from '../code-line/LineCoverage'; + +it('should render correctly when covered', () => { + expect(setupWithProps().container).toMatchSnapshot(); +}); + +it('should render correctly when uncovered', () => { + expect( + setupWithProps({ lineNumber: 16, coverageStatus: 'uncovered' }).container + ).toMatchSnapshot(); +}); + +it('should render correctly when partially covered without conditions', () => { + expect( + setupWithProps({ + lineNumber: 16, + coverageStatus: 'partially-covered', + }).container + ).toMatchSnapshot(); +}); + +it('should render correctly when partially covered with 5/10 conditions', () => { + expect( + setupWithProps({ + lineNumber: 16, + coverageStatus: 'partially-covered', + }).container + ).toMatchSnapshot(); +}); + +it('should render correctly when no data', () => { + expect(setupWithProps({ lineNumber: 16, coverageStatus: undefined }).container).toMatchSnapshot(); +}); + +function setupWithProps(props: Partial> = {}) { + return render(); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx new file mode 100644 index 00000000000..aee31448c74 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/LineNumber-test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { screen } from '@testing-library/react'; +import { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { LineNumber } from '../code-line/LineNumber'; + +it('should a popup when clicked', async () => { + const { user } = setupWithProps(); + + expect(screen.getByRole('button', { name: 'aria-label' })).toBeVisible(); + + await user.click(screen.getByRole('button', { name: 'aria-label' })); + expect(screen.getByText('Popup')).toBeVisible(); +}); + +function setupWithProps(props: Partial> = {}) { + return render( + Popup} + {...props} + /> + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx new file mode 100644 index 00000000000..0bdc69d22b2 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/LineWrapper-test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { render } from '../../helpers/testUtils'; +import { FCProps } from '../../types/misc'; +import { LineWrapper } from '../code-line/LineWrapper'; + +it('should render with correct styling', () => { + expect(setupWithProps().container).toMatchSnapshot(); +}); + +it('should properly setup css grid columns', () => { + expect(setupWithProps().container.firstChild).toHaveStyle({ + '--columns': '44px 50px 26px repeat(3, 6px) 1fr', + }); + expect(setupWithProps({ duplicationsCount: 0 }).container.firstChild).toHaveStyle({ + '--columns': '44px 50px 26px repeat(1, 6px) 1fr', + }); + expect( + setupWithProps({ displayCoverage: false, displaySCM: false, duplicationsCount: 0 }).container + .firstChild + ).toHaveStyle({ '--columns': '44px 26px 1fr' }); +}); + +it('should set a highlighted background color in css props', () => { + const { container } = setupWithProps({ highlighted: true }); + expect(container.firstChild).toHaveStyle({ '--line-background': 'rgb(225,230,243)' }); +}); + +function setupWithProps(props: Partial> = {}) { + return render( + + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap index 5b27cf29f1f..ec00317fed2 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap @@ -112,7 +112,7 @@ exports[`should show full size when multiline with no editting 1`] = ` background: rgb(252,252,253); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 400; } @@ -136,7 +136,7 @@ exports[`should show full size when multiline with no editting 1`] = ` color: rgb(109,111,119); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-style: italic; } @@ -146,7 +146,7 @@ exports[`should show full size when multiline with no editting 1`] = ` color: rgb(152,29,150); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 700; } @@ -353,7 +353,7 @@ exports[`should show reduced size when single line with no editting 1`] = ` background: rgb(252,252,253); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 400; } @@ -377,7 +377,7 @@ exports[`should show reduced size when single line with no editting 1`] = ` color: rgb(109,111,119); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-style: italic; } @@ -387,7 +387,7 @@ exports[`should show reduced size when single line with no editting 1`] = ` color: rgb(152,29,150); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 700; } diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap index 45b4dd7b63b..d7fed058225 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap @@ -19,7 +19,7 @@ exports[`renders correctly 1`] = ` background: rgb(252,252,253); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 400; } @@ -43,7 +43,7 @@ exports[`renders correctly 1`] = ` color: rgb(109,111,119); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-style: italic; } @@ -53,7 +53,7 @@ exports[`renders correctly 1`] = ` color: rgb(152,29,150); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 700; } @@ -127,7 +127,7 @@ exports[`should display edit functions 1`] = ` background: rgb(252,252,253); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 400; } @@ -151,7 +151,7 @@ exports[`should display edit functions 1`] = ` color: rgb(109,111,119); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-style: italic; } @@ -161,7 +161,7 @@ exports[`should display edit functions 1`] = ` color: rgb(152,29,150); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 700; } @@ -321,7 +321,7 @@ exports[`should handle multiple lines of code 1`] = ` background: rgb(252,252,253); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 400; } @@ -345,7 +345,7 @@ exports[`should handle multiple lines of code 1`] = ` color: rgb(109,111,119); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-style: italic; } @@ -355,7 +355,7 @@ exports[`should handle multiple lines of code 1`] = ` color: rgb(152,29,150); font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; font-size: 0.875rem; - line-height: 1.25rem; + line-height: 1.125rem; font-weight: 700; } diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap new file mode 100644 index 00000000000..88462699f2f --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineCoverage-test.tsx.snap @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly when covered 1`] = ` +.emotion-0 { + color: rgb(166,173,194); + background-color: var(--line-background); + outline: none; + height: 100%; + width: 100%; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.e97pm2l12:hover .emotion-0 { + background-color: rgb(239,242,249); +} + +.emotion-2 { + height: 100%; + width: 0.25rem; + margin-left: 0.125rem; + background-color: rgb(166,244,197); +} + +.emotion-2, +.emotion-2 svg { + outline: none; +} + +
    + +
    + +
    +`; + +exports[`should render correctly when no data 1`] = ` +.emotion-0 { + color: rgb(166,173,194); + background-color: var(--line-background); + outline: none; + height: 100%; + width: 100%; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.e97pm2l12:hover .emotion-0 { + background-color: rgb(239,242,249); +} + +
    + +
    +`; + +exports[`should render correctly when partially covered with 5/10 conditions 1`] = ` +.emotion-0 { + color: rgb(166,173,194); + background-color: var(--line-background); + outline: none; + height: 100%; + width: 100%; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.e97pm2l12:hover .emotion-0 { + background-color: rgb(239,242,249); +} + +.emotion-2 { + height: 100%; + width: 0.25rem; + margin-left: 0.125rem; +} + +.emotion-2, +.emotion-2 svg { + outline: none; +} + +
    + +
    + + + + +
    + +
    +`; + +exports[`should render correctly when partially covered without conditions 1`] = ` +.emotion-0 { + color: rgb(166,173,194); + background-color: var(--line-background); + outline: none; + height: 100%; + width: 100%; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.e97pm2l12:hover .emotion-0 { + background-color: rgb(239,242,249); +} + +.emotion-2 { + height: 100%; + width: 0.25rem; + margin-left: 0.125rem; +} + +.emotion-2, +.emotion-2 svg { + outline: none; +} + +
    + +
    + + + + +
    + +
    +`; + +exports[`should render correctly when uncovered 1`] = ` +.emotion-0 { + color: rgb(166,173,194); + background-color: var(--line-background); + outline: none; + height: 100%; + width: 100%; + box-sizing: border-box; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.e97pm2l12:hover .emotion-0 { + background-color: rgb(239,242,249); +} + +.emotion-2 { + height: 100%; + width: 0.25rem; + margin-left: 0.125rem; + background-color: rgb(217,45,32); +} + +.emotion-2, +.emotion-2 svg { + outline: none; +} + +
    + +
    + +
    +`; diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap new file mode 100644 index 00000000000..2bbcd9fed07 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineWrapper-test.tsx.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render with correct styling 1`] = ` +.emotion-0 { + display: grid; + grid-template-rows: auto; + grid-template-columns: var(--columns); + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + font-family: Ubuntu Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; + font-size: 0.875rem; + line-height: 1.125rem; + font-weight: 400; +} + +
    + +
    +`; diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx index 6e955a2bb04..dd679d3ae56 100644 --- a/server/sonar-web/design-system/src/components/buttons.tsx +++ b/server/sonar-web/design-system/src/components/buttons.tsx @@ -255,3 +255,34 @@ export const CodeViewerExpander = styled(BareButton)` border-bottom: ${(props) => props.direction === 'UP' ? themeBorder('default', 'codeLineBorder') : 'none'}; `; + +export const IssueIndicatorButton = styled(BareButton)` + color: ${themeColor('codeLineMeta')}; + text-decoration: none; + + ${tw`sw-whitespace-nowrap`} +`; + +export const DuplicationBlock = styled(BareButton)` + background-color: ${themeColor('codeLineDuplication')}; + outline: none; + + ${tw`sw-block`} + ${tw`sw-w-1 sw-h-full`} + ${tw`sw-ml-1/2`} + ${tw`sw-cursor-pointer`} +`; + +export const LineSCMStyled = styled(BareButton)` + outline: none; + + ${tw`sw-pr-2`} + ${tw`sw-truncate`} + ${tw`sw-whitespace-nowrap`} + ${tw`sw-cursor-pointer`} + ${tw`sw-w-full sw-h-full`} + +&:hover { + color: ${themeColor('codeLineMetaHover')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx b/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx new file mode 100644 index 00000000000..726b3543a49 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineCoverage.tsx @@ -0,0 +1,97 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import React, { memo } from 'react'; +import tw from 'twin.macro'; +import { PopupPlacement } from '../../helpers/positioning'; +import { themeColor } from '../../helpers/theme'; +import Tooltip from '../Tooltip'; +import { LineMeta } from './LineStyles'; + +interface Props { + coverageStatus?: 'uncovered' | 'partially-covered' | 'covered'; + lineNumber: number; + scrollToUncoveredLine?: boolean; + status: string | undefined; +} + +function LineCoverageFunc({ lineNumber, coverageStatus, status, scrollToUncoveredLine }: Props) { + const coverageMarker = React.useRef(null); + React.useEffect(() => { + if (scrollToUncoveredLine && coverageMarker.current) { + coverageMarker.current.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + } + }, [scrollToUncoveredLine, coverageMarker]); + + if (!coverageStatus) { + return ; + } + + return ( + + + {coverageStatus === 'covered' && } + {coverageStatus === 'uncovered' && } + {coverageStatus === 'partially-covered' && } + + + ); +} + +export const LineCoverage = memo(LineCoverageFunc); + +const CoverageBlock = styled.div` + ${tw`sw-w-1 sw-h-full`} + ${tw`sw-ml-1/2`} + + &, & svg { + outline: none; + } +`; + +const CoveredBlock = styled(CoverageBlock)` + background-color: ${themeColor('codeLineCovered')}; +`; + +const UncoveredBlock = styled(CoverageBlock)` + background-color: ${themeColor('codeLineUncovered')}; +`; + +function PartiallyCoveredBlock(htmlProps: React.HTMLAttributes) { + const theme = useTheme(); + return ( + + + + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx b/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx new file mode 100644 index 00000000000..a5522960a15 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineIssuesIndicatorIcon.tsx @@ -0,0 +1,49 @@ +/* + * 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 { memo } from 'react'; +import tw from 'twin.macro'; +import { IssueTypeIcon } from '../icons/IssueTypeIcon'; + +export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT'; + +interface Props { + issuesCount: number; + mostImportantIssueType: IssueType; +} + +function LineIssueIndicatorIconFunc({ issuesCount, mostImportantIssueType }: Props) { + return ( + <> + + {issuesCount > 1 && {issuesCount}} + + ); +} + +export const LineIssuesIndicatorIcon = memo(LineIssueIndicatorIconFunc); + +const IssueIndicatorCounter = styled.span` + font-size: 0.5rem; + line-height: 0.5rem; + + ${tw`sw-ml-1/2`} + ${tw`sw-align-top`} +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx b/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx new file mode 100644 index 00000000000..cf62c597baa --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineMarker.tsx @@ -0,0 +1,98 @@ +/* + * 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 classNames from 'classnames'; +import { forwardRef, Ref, useCallback, useRef } from 'react'; +import tw from 'twin.macro'; +import { themeColor, themeContrast } from '../../helpers/theme'; +import { IssueLocationMarker } from '../IssueLocationMarker'; + +interface Props { + hideLocationIndex?: boolean; + index: number; + leading?: boolean; + message?: string; + onLocationSelect?: (index: number) => void; + selected: boolean; +} + +function LineMarkerFunc( + { hideLocationIndex, index, leading, message, onLocationSelect, selected }: Props, + ref: Ref +) { + const element = useRef(null); + const elementMessage = useRef(null); + + const handleClick = useCallback(() => { + onLocationSelect?.(index); + }, [index, onLocationSelect]); + + return ( + + + {message && {message}} + + ); +} + +const Message = styled.div` + ${tw`sw-absolute`} + ${tw`sw-body-sm`} + ${tw`sw-rounded-1/2`} + ${tw`sw-px-1`} + ${tw`sw-left-0`} + + bottom: calc(100% + 0.25rem); + width: max-content; + max-width: var(--max-width); + color: ${themeContrast('codeLineIssueMessageTooltip')}; + background-color: ${themeColor('codeLineIssueMessageTooltip')}; + visibility: hidden; + + &.message-right { + ${tw`sw-left-auto`} + ${tw`sw-right-0`} + } +`; + +const Wrapper = styled.div` + ${tw`sw-relative`} + ${tw`sw-inline-block`} + ${tw`sw-align-top`} + + &:not(:first-of-type) { + ${tw`sw-ml-1`} + } + + &.leading { + margin-left: calc(var(--width) - 0.25rem); + } + + &:hover ${Message} { + visibility: visible; + } +`; + +export const LineMarker = forwardRef(LineMarkerFunc); diff --git a/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx b/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx new file mode 100644 index 00000000000..243b2df0a93 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineNumber.tsx @@ -0,0 +1,95 @@ +/* + * 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 { memo, useState } from 'react'; +import tw from 'twin.macro'; +import { PopupPlacement, PopupZLevel } from '../../helpers/positioning'; +import { themeColor } from '../../helpers/theme'; +import { DropdownToggler } from '../DropdownToggler'; +import { LineMeta } from './LineStyles'; + +interface Props { + ariaLabel: string; + displayOptions: boolean; + firstLineNumber: number; + lineNumber: number; + popup: React.ReactNode; +} + +const FILE_TOP_THRESHOLD = 10; + +function LineNumberFunc({ firstLineNumber, lineNumber, popup, displayOptions, ariaLabel }: Props) { + const [isOpen, setIsOpen] = useState(false); + + const hasLineNumber = Boolean(lineNumber); + const isFileTop = lineNumber - FILE_TOP_THRESHOLD < firstLineNumber; + + if (!hasLineNumber) { + return ; + } + + return ( + + {displayOptions ? ( + { + setIsOpen(false); + }} + open={isOpen} + overlay={popup} + placement={isFileTop ? PopupPlacement.Bottom : PopupPlacement.Top} + zLevel={PopupZLevel.Global} + > + { + setIsOpen(true); + }} + role="button" + tabIndex={0} + > + {lineNumber} + + + ) : ( + lineNumber + )} + + ); +} + +export const LineNumber = memo(LineNumberFunc); + +const LineNumberStyled = styled.div` + outline: none; + + ${tw`sw-pr-2`} + ${tw`sw-cursor-pointer`} + + &:hover { + color: ${themeColor('codeLineMetaHover')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx new file mode 100644 index 00000000000..88975e22e16 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineStyles.tsx @@ -0,0 +1,145 @@ +/* + * 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, themeContrast } from '../../helpers/theme'; + +export const SCMHighlight = styled.h6` + color: ${themeColor('tooltipHighlight')}; + + ${tw`sw-body-sm-highlight`}; + ${tw`sw-text-right`}; + ${tw`sw-min-w-[6rem]`}; + ${tw`sw-mr-4`}; + ${tw`sw-my-1`}; +`; + +export const LineSCMStyledDiv = styled.div` + outline: none; + + ${tw`sw-pr-2`} + ${tw`sw-truncate`} +${tw`sw-whitespace-nowrap`} +${tw`sw-cursor-pointer`} +${tw`sw-w-full sw-h-full`} + +&:hover { + color: ${themeColor('codeLineMetaHover')}; + } +`; + +export const DuplicationHighlight = styled.h6` + color: ${themeColor('tooltipHighlight')}; + + ${tw`sw-mb-2 sw-font-semibold`}; +`; + +export const LineStyled = styled.tr` + display: grid; + grid-template-rows: auto; + grid-template-columns: var(--columns); + align-items: center; + + ${tw`sw-code`} +`; +LineStyled.displayName = 'LineStyled'; + +export const LineMeta = styled.td` + color: ${themeColor('codeLineMeta')}; + background-color: var(--line-background); + outline: none; + + ${tw`sw-w-full sw-h-full`} + ${tw`sw-box-border`} + ${tw`sw-select-none`} + + ${LineStyled}:hover & { + background-color: ${themeColor('codeLineHover')}; + } +`; + +export const LineCodePreFormatted = styled.pre` + position: relative; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 4; +`; + +export const LineCodeLayer = styled.div` + grid-row: 1; + grid-column: 1; +`; + +export const LineCodeLayers = styled.td` + position: relative; + display: grid; + height: 100%; + background-color: var(--line-background); + border-left: ${themeBorder('default', 'codeLineBorder')}; + + ${LineStyled}:hover & { + background-color: ${themeColor('codeLineHover')}; + } +`; + +export const NewCodeUnderline = styled(LineCodeLayer)` + background-color: ${themeColor('codeLineNewCodeUnderline')}; +`; + +export const CoveredUnderline = styled(LineCodeLayer)` + background-color: ${themeColor('codeLineCoveredUnderline')}; +`; + +export const UncoveredUnderline = styled(LineCodeLayer)` + background-color: ${themeColor('codeLineUncoveredUnderline')}; +`; + +export const UnderlineLabels = styled.div<{ transparentBackground?: boolean }>` + ${tw`sw-absolute`} + ${tw`sw-flex sw-gap-1`} + ${tw`sw-px-1`} + ${tw`sw-right-0`} + + + height: 1.125rem; + margin-top: -1.125rem; + background-color: ${({ transparentBackground, theme }) => + themeColor(transparentBackground ? 'transparent' : 'codeLine')({ theme })}; +`; + +export const UnderlineLabel = styled.span` + ${tw`sw-rounded-t-1`} + ${tw`sw-px-1`} +`; + +export const NewCodeUnderlineLabel = styled(UnderlineLabel)` + color: ${themeContrast('codeLineNewCodeUnderline')}; + background-color: ${themeColor('codeLineNewCodeUnderline')}; +`; + +export const CoveredUnderlineLabel = styled(UnderlineLabel)` + color: ${themeContrast('codeLineCoveredUnderline')}; + background-color: ${themeColor('codeLineCoveredUnderline')}; +`; + +export const UncoveredUnderlineLabel = styled(UnderlineLabel)` + color: ${themeContrast('codeLineUncoveredUnderline')}; + background-color: ${themeColor('codeLineUncoveredUnderline')}; +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineToken.tsx b/server/sonar-web/design-system/src/components/code-line/LineToken.tsx new file mode 100644 index 00000000000..b96667a6df9 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineToken.tsx @@ -0,0 +1,91 @@ +/* + * 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 classNames from 'classnames'; +import { ReactNode } from 'react'; +import tw from 'twin.macro'; +import { themeColor } from '../../helpers/theme'; + +export interface TokenModifiers { + isHighlighted?: boolean; + isLocation?: boolean; + isSelected?: boolean; + isUnderlined?: boolean; +} + +interface Props extends TokenModifiers { + children: ReactNode; + className?: string; + hasMarker?: boolean; +} + +export function LineToken(props: Props) { + const { children, className, hasMarker, ...modifiers } = props; + + return ( + + <>{children} + + ); +} + +const TokenStyled = styled.span` + display: inline-block; + + &.sym { + ${tw`sw-cursor-pointer`} + } + + &.sym.highlighted { + background-color: ${themeColor('codeLineLocationHighlighted')}; + transition: background-color 0.3s; + } + + &.issue-underline { + position: relative; + z-index: 1; + text-decoration: underline ${themeColor('codeLineIssueSquiggle')}; + text-decoration: underline ${themeColor('codeLineIssueSquiggle')} wavy; + text-decoration-thickness: 2px; + text-decoration-skip-ink: none; + } + + &.issue-location { + line-height: 1.125rem; + background-color: ${themeColor('codeLineIssueLocation')}; + transition: background-color 0.3s ease; + } + + &.issue-location.selected { + background-color: ${themeColor('codeLineIssueLocationSelected')}; + } + + &.issue-location.has-marker { + ${tw`sw-pl-1`} + } +`; diff --git a/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx new file mode 100644 index 00000000000..5b825c82608 --- /dev/null +++ b/server/sonar-web/design-system/src/components/code-line/LineWrapper.tsx @@ -0,0 +1,49 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import { HTMLAttributes } from 'react'; +import { themeColor } from '../../helpers/theme'; +import { LineStyled } from './LineStyles'; + +interface Props extends HTMLAttributes { + displayCoverage: boolean; + displaySCM: boolean; + duplicationsCount: number; + highlighted: boolean; +} + +export function LineWrapper(props: Props) { + const { displayCoverage, displaySCM, duplicationsCount, highlighted, ...htmlProps } = props; + const theme = useTheme(); + const SCMCol = displaySCM ? '50px ' : ''; + const nbGutters = duplicationsCount + (displayCoverage ? 1 : 0); + const gutterCols = nbGutters > 0 ? `repeat(${nbGutters}, 6px) ` : ''; + return ( + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx b/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx new file mode 100644 index 00000000000..134788f9618 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/IssueTypeIcon.tsx @@ -0,0 +1,85 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import tw from 'twin.macro'; +import { themeColor, themeContrast } from '../../helpers/theme'; +import { BugIcon } from './BugIcon'; +import { CodeSmellIcon } from './CodeSmellIcon'; +import { IconProps } from './Icon'; +import { SecurityFindingIcon } from './SecurityFindingIcon'; +import { VulnerabilityIcon } from './VulnerabilityIcon'; + +export type IssueType = 'BUG' | 'VULNERABILITY' | 'CODE_SMELL' | 'SECURITY_HOTSPOT'; +export interface Props extends IconProps { + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + type: string | IssueType; +} + +export enum IssueTypeEnum { + CODE_SMELL = 'CODE_SMELL', + VULNERABILITY = 'VULNERABILITY', + BUG = 'BUG', + SECURITY_HOTSPOT = 'SECURITY_HOTSPOT', +} + +export function IssueTypeIcon({ type, ...iconProps }: Props) { + switch (type.toLowerCase()) { + case IssueTypeEnum.BUG.toLowerCase(): + case 'bugs': + case 'new_bugs': + case IssueTypeEnum.BUG: + return ; + case IssueTypeEnum.VULNERABILITY.toLowerCase(): + case 'vulnerabilities': + case 'new_vulnerabilities': + case IssueTypeEnum.VULNERABILITY: + return ; + case IssueTypeEnum.CODE_SMELL.toLowerCase(): + case 'code_smells': + case 'new_code_smells': + case IssueTypeEnum.CODE_SMELL: + return ; + case IssueTypeEnum.SECURITY_HOTSPOT.toLowerCase(): + case 'security_hotspots': + case 'new_security_hotspots': + case IssueTypeEnum.SECURITY_HOTSPOT: + return ; + default: + return null; + } +} + +export function IssueTypeCircleIcon({ className, type, ...iconProps }: Props) { + const theme = useTheme(); + return ( + + + + ); +} + +const CircleIconContainer = styled.div` + ${tw`sw-w-6 sw-h-6`} + ${tw`sw-inline-flex sw-items-center sw-justify-center sw-shrink-0`}; + + background: ${themeColor('issueTypeIcon')}; + border-radius: 100%; +`; diff --git a/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx b/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx new file mode 100644 index 00000000000..4f5bbc4d99b --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/QualifierIcon.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { DirectoryIcon } from './DirectoryIcon'; +import { FileIcon } from './FileIcon'; +import { IconProps } from './Icon'; +import { ProjectIcon } from './ProjectIcon'; +import { TestFileIcon } from './TestFileIcon'; + +interface Props extends IconProps { + qualifier: string | null | undefined; +} + +export function QualifierIcon({ qualifier, fill, ...iconProps }: Props) { + const theme = useTheme(); + + if (!qualifier) { + return null; + } + + const icon = { + dir: , + fil: , + trk: , + uts: , + }[qualifier.toLowerCase()]; + + return icon ?? null; +} diff --git a/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx b/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx new file mode 100644 index 00000000000..70bf1d3a71d --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/SecurityFindingIcon.tsx @@ -0,0 +1,37 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { CustomIcon, IconProps } from './Icon'; + +export function SecurityFindingIcon({ fill = 'currentColor', ...iconProps }: IconProps) { + const theme = useTheme(); + const fillColor = themeColor(fill)({ theme }); + return ( + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx new file mode 100644 index 00000000000..fae7278a2f6 --- /dev/null +++ b/server/sonar-web/design-system/src/components/icons/TestFileIcon.tsx @@ -0,0 +1,43 @@ +/* + * 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 { useTheme } from '@emotion/react'; +import { themeColor } from '../../helpers/theme'; +import { CustomIcon, IconProps } from './Icon'; + +export function TestFileIcon({ fill = 'currentColor', ...iconProps }: IconProps) { + const theme = useTheme(); + const fillColor = themeColor(fill)({ theme }); + return ( + + + + + ); +} diff --git a/server/sonar-web/design-system/src/components/icons/index.ts b/server/sonar-web/design-system/src/components/icons/index.ts index 492863fb778..b811ad0071d 100644 --- a/server/sonar-web/design-system/src/components/icons/index.ts +++ b/server/sonar-web/design-system/src/components/icons/index.ts @@ -53,6 +53,7 @@ export { OverviewQGPassedIcon } from './OverviewQGPassedIcon'; export { PencilIcon } from './PencilIcon'; export { ProjectIcon } from './ProjectIcon'; export { PullRequestIcon } from './PullRequestIcon'; +export { QualifierIcon } from './QualifierIcon'; export { RefreshIcon } from './RefreshIcon'; export { RequiredIcon } from './RequiredIcon'; export { SecurityHotspotIcon } from './SecurityHotspotIcon'; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index f1880121fde..fb80d478c1a 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -62,12 +62,14 @@ export * from './MainMenuItem'; export * from './MetricsRatingBadge'; export * from './NavBarTabs'; export * from './NewCodeLegend'; +export * from './OutsideClickHandler'; export { QualityGateIndicator } from './QualityGateIndicator'; export * from './SearchSelect'; export * from './SearchSelectDropdown'; export * from './SelectionCard'; export * from './Separator'; export * from './SizeIndicator'; +export * from './SonarCodeColorizer'; export * from './SonarQubeLogo'; export * from './Table'; export * from './Tags'; @@ -77,6 +79,13 @@ export { ToggleButton } from './ToggleButton'; export { TopBar } from './TopBar'; export * from './buttons'; export { ClipboardIconButton } from './clipboard'; +export * from './code-line/LineCoverage'; +export * from './code-line/LineIssuesIndicatorIcon'; +export * from './code-line/LineMarker'; +export * from './code-line/LineNumber'; +export * from './code-line/LineStyles'; +export * from './code-line/LineToken'; +export * from './code-line/LineWrapper'; export * from './icons'; export * from './layouts'; export * from './modal/Modal'; diff --git a/server/sonar-web/design-system/src/theme/colors.ts b/server/sonar-web/design-system/src/theme/colors.ts index 785f6f07c8b..81bb8e5dd6d 100644 --- a/server/sonar-web/design-system/src/theme/colors.ts +++ b/server/sonar-web/design-system/src/theme/colors.ts @@ -133,4 +133,44 @@ export default { 800: [49, 108, 146], 900: [23, 67, 97], }, + codeSnippetLight: { + body: [51, 53, 60], + annotations: [34, 84, 192], + constants: [126, 83, 5], + comments: [109, 111, 119], + keyword: [152, 29, 150], + string: [32, 105, 31], + 'keyword-light': [28, 28, 163], // Not used currently in code snippet + 'preprocessing-directive': [47, 103, 48], + }, + codeSnippetDark: { + body: [241, 245, 253], + annotations: [137, 214, 255], + constants: [237, 182, 130], + comments: [156, 164, 175], + keyword: [251, 173, 255], + string: [177, 220, 146], + 'keyword-light': [185, 185, 255], // Not used currently in code snippet + 'preprocessing-directive': [133, 228, 134], + }, + codeSyntaxLight: { + body: [56, 58, 66], + annotations: [35, 91, 213], + constants: [135, 87, 2], + comments: [95, 96, 102], + keyword: [162, 34, 160], + string: [36, 117, 35], + 'keyword-light': [30, 30, 173], + 'preprocessing-directive': [52, 114, 53], + }, + codeSyntaxDark: { + body: [226, 231, 241], + annotations: [97, 174, 238], + constants: [209, 154, 102], + comments: [167, 172, 180], + keyword: [223, 145, 246], + string: [152, 195, 121], + 'keyword-light': [171, 171, 255], + 'preprocessing-directive': [120, 215, 121], + }, }; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index fb4ddc7a1d0..dd154c09921 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.ts @@ -220,6 +220,16 @@ export const lightTheme = { codeLineIssueLocationSelected: [...danger.lighter, 0.5], codeLineIssueMessageTooltip: secondary.darker, + // code syntax highlight + codeSyntaxBody: COLORS.codeSyntaxLight.body, + codeSyntaxAnnotations: COLORS.codeSyntaxLight.annotations, + codeSyntaxConstants: COLORS.codeSyntaxLight.constants, + codeSyntaxComments: COLORS.codeSyntaxLight.comments, + codeSyntaxKeyword: COLORS.codeSyntaxLight.keyword, + codeSyntaxString: COLORS.codeSyntaxLight.string, + codeSyntaxKeywordLight: COLORS.codeSyntaxLight['keyword-light'], + codeSyntaxPreprocessingDirective: COLORS.codeSyntaxLight['preprocessing-directive'], + // checkbox checkboxHover: COLORS.indigo[50], checkboxCheckedHover: primary.light, @@ -248,6 +258,7 @@ export const lightTheme = { // tooltip tooltipBackground: COLORS.blueGrey[600], tooltipSeparator: secondary.dark, + tooltipHighlight: secondary.default, // avatar avatarBackground: COLORS.white, diff --git a/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css b/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css deleted file mode 100644 index bc871a101f3..00000000000 --- a/server/sonar-web/src/main/js/app/styles/sonar-colorizer.css +++ /dev/null @@ -1,92 +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. - */ -/* for example java annotations */ -.code .a { - color: #808000; -} - -/* constants */ -.code .c { - color: #660e80; - font-style: normal; - font-weight: bold; -} - -/* javadoc */ -.code .j { - color: #666666; - font-style: normal; -} - -/* classic comment */ -.code .cd { - color: #666666; - font-style: italic; -} - -/* C++ doc */ -.code .cppd { - color: #666666; - font-style: italic; -} - -/* keyword */ -.code .k { - color: #0071ba; - font-weight: 600; -} - -/* string */ -.code .s { - color: #277b31; - font-weight: normal; -} - -/* keyword light*/ -.code .h { - color: #000080; - font-weight: normal; -} - -/* preprocessing directive */ -.code .p { - color: #347235; - font-weight: normal; -} - -.sym { - cursor: hand; - cursor: pointer; -} - -.highlighted { - background-color: var(--lightBlue); - animation: highlightedFadeIn 0.3s forwards; -} - -@keyframes highlightedFadeIn { - from { - background-color: transparent; - } - - to { - background-color: var(--lightBlue); - } -} diff --git a/server/sonar-web/src/main/js/app/styles/sonar.ts b/server/sonar-web/src/main/js/app/styles/sonar.ts index ecaa397f678..c8f2b691709 100644 --- a/server/sonar-web/src/main/js/app/styles/sonar.ts +++ b/server/sonar-web/src/main/js/app/styles/sonar.ts @@ -46,5 +46,4 @@ import './init/tables.css'; import './init/type.css'; import './mixins.css'; import './print.css'; -import './sonar-colorizer.css'; import './style.css'; diff --git a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx index 014471b2085..59d2f52ade8 100644 --- a/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/__tests__/IssuesSourceViewer-it.tsx @@ -19,7 +19,7 @@ */ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { byRole, byText } from 'testing-library-selector'; +import { byRole } from 'testing-library-selector'; import { componentsHandler, issuesHandler, @@ -47,9 +47,7 @@ const ui = { scmInfoLine60: byRole('button', { name: 'source_viewer.author_X.simon.brandhof@sonarsource.com, source_viewer.click_for_scm_info.1', - expanded: false, }), - scmInfoExpanded: byText('80f564becc0c0a1c9abaa006eca83a4fd278c3f0'), }; describe('issues source viewer', () => { @@ -83,9 +81,9 @@ describe('issues source viewer', () => { expect(ui.line199.query()).not.toBeInTheDocument(); // Expand should only expand a few lines, not all of them // Show SCM info for newly expanded line - expect(ui.scmInfoExpanded.query()).not.toBeInTheDocument(); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); await user.click(ui.scmInfoLine60.get()); - expect(ui.scmInfoExpanded.get()).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toBeVisible(); }); it('should expand all lines', async () => { diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx index 93e16726fbc..f6029dfa639 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetGroupViewer.tsx @@ -62,7 +62,6 @@ interface Props { isLastOccurenceOfPrimaryComponent: boolean; issue: TypeIssue; issuesByLine: IssuesByLine; - lastSnippetGroup: boolean; loadDuplications: (component: string, line: SourceLine) => void; locations: FlowLocation[]; onIssueSelect: (issueKey: string) => void; @@ -256,8 +255,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone }; render() { - const { branchLike, isLastOccurenceOfPrimaryComponent, issue, lastSnippetGroup, snippetGroup } = - this.props; + const { branchLike, isLastOccurenceOfPrimaryComponent, issue, snippetGroup } = this.props; const { additionalLines, loading, snippets } = this.state; const snippetLines = linesForSnippets(snippets, { @@ -320,7 +318,7 @@ export default class ComponentSourceSnippetGroupViewer extends React.PureCompone )} - {snippetLines.map((snippet, index) => ( + {snippetLines.map(({ snippet, sourcesMap }, index) => ( ))} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx index d1ec05f8ba0..8e5e8551a02 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx @@ -21,6 +21,7 @@ import { findLastIndex, keyBy } from 'lodash'; import * as React from 'react'; import { getComponentForSourceViewer, getDuplications, getSources } from '../../../api/components'; import { getIssueFlowSnippets } from '../../../api/issues'; +import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext'; import DuplicationPopup from '../../../components/SourceViewer/components/DuplicationPopup'; import { filterDuplicationBlocksByLine, @@ -31,7 +32,6 @@ import { duplicationsByLine as getDuplicationsByLine, issuesByComponentAndLine, } from '../../../components/SourceViewer/helpers/indexing'; -import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext'; import { Alert } from '../../../components/ui/Alert'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { WorkspaceContext } from '../../../components/workspace/context'; @@ -175,10 +175,11 @@ export default class CrossComponentSourceViewer extends React.PureComponent )} @@ -234,7 +235,6 @@ export default class CrossComponentSourceViewer extends React.PureComponent; - lastSnippetOfLastGroup: boolean; loadDuplications?: (line: SourceLine) => void; locations: FlowLocation[]; locationsByLine: { [line: number]: LinearIssueLocation[] }; @@ -68,154 +66,142 @@ export interface SnippetViewerProps { renderDuplicationPopup: (index: number, line: number) => React.ReactNode; snippet: SourceLine[]; className?: string; + snippetSourcesMap?: LineMap; } -class SnippetViewer extends React.PureComponent { - expandBlock = (direction: ExpandDirection) => () => - this.props.expandBlock(this.props.index, direction); +type Props = SnippetViewerProps & ThemeProp; - renderLine({ - displayDuplications, - displaySCM, - index, - issueLocations, - line, - snippet, - symbols, - verticalBuffer, - }: { - displayDuplications: boolean; - displaySCM?: boolean; - index: number; - issueLocations: LinearIssueLocation[]; - line: SourceLine; - snippet: SourceLine[]; - symbols: string[]; - verticalBuffer: number; - }) { - const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations); +function SnippetViewer(props: Props) { + const expandBlock = (direction: ExpandDirection) => () => { + props.expandBlock(props.index, direction); + }; - const { displayLineNumberOptions, duplications, duplicationsByLine } = this.props; - const duplicationsCount = duplications ? duplications.length : 0; - const lineDuplications = - (duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || []; + const { component, displaySCM, locationsByLine, snippet, theme, className } = props; - const firstLineNumber = snippet && snippet.length ? snippet[0].line : 0; - const noop = () => {}; + const { displayLineNumberOptions, duplications, duplicationsByLine, snippetSourcesMap } = props; + const duplicationsCount = duplications ? duplications.length : 0; - return ( - 0 ? snippet[index - 1] : undefined} - renderDuplicationPopup={this.props.renderDuplicationPopup} - secondaryIssueLocations={secondaryIssueLocations} - verticalBuffer={verticalBuffer} - > - {this.props.renderAdditionalChildInLine && this.props.renderAdditionalChildInLine(line)} - - ); - } + const firstLineNumber = snippet?.length ? snippet[0].line : 0; + const noop = () => { + /* noop */ + }; + const lastLine = component.measures?.lines && parseInt(component.measures.lines, 10); + + const symbols = symbolsByLine(snippet); - render() { - const { - component, - displaySCM, - issue, - lastSnippetOfLastGroup, - locationsByLine, - snippet, - theme, - className, - } = this.props; - const lastLine = - component.measures && component.measures.lines && parseInt(component.measures.lines, 10); + const displayDuplications = + Boolean(props.loadDuplications) && snippet.some((s) => !!s.duplicated); - const symbols = symbolsByLine(snippet); + const borderColor = themeColor('codeLineBorder')({ theme }); - const bottomLine = snippet[snippet.length - 1].line; - const issueLine = issue.textRange ? issue.textRange.endLine : issue.line; + const THROTTLE_SHORT_DELAY = 10; + const [hoveredLine, setHoveredLine] = React.useState(); - const verticalBuffer = - lastSnippetOfLastGroup && issueLine - ? Math.max(0, LINES_BELOW_ISSUE - (bottomLine - issueLine)) - : 0; + const onLineMouseEnter = React.useMemo( + () => + throttle( + (hoveredLine: number) => + snippetSourcesMap ? setHoveredLine(snippetSourcesMap[hoveredLine]) : undefined, + THROTTLE_SHORT_DELAY + ), + [snippetSourcesMap] + ); - const displayDuplications = - Boolean(this.props.loadDuplications) && snippet.some((s) => !!s.duplicated); + const onLineMouseLeave = React.useMemo( + () => + debounce( + (line: number) => + setHoveredLine((hoveredLine) => (hoveredLine?.line === line ? undefined : hoveredLine)), + THROTTLE_SHORT_DELAY + ), + [] + ); - const borderColor = themeColor('codeLineBorder')({ theme }); + return ( +
    + + {snippet[0].line > 1 && ( + + + + )} + + + {snippet.map((line, index) => { + const secondaryIssueLocations = getSecondaryIssueLocationsForLine( + line, + props.locations + ); + const lineDuplications = + (duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || []; - return ( -
    -
    - {snippet[0].line > 1 && ( - - - - )} -
    - - {snippet.map((line, index) => - this.renderLine({ - displayDuplications, - displaySCM, - index, - issueLocations: locationsByLine[line.line] || [], - line, - snippet, - symbols: symbols[line.line], - verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0, - }) - )} - -
    - {(!lastLine || snippet[snippet.length - 1].line < lastLine) && ( - - - - )} -
    -
    - ); - } + const displayCoverageUnderline = hoveredLine?.coverageBlock === line.coverageBlock; + const displayNewCodeUnderline = hoveredLine?.newCodeBlock === line.line; + return ( + 0 ? snippet[index - 1] : undefined} + renderDuplicationPopup={props.renderDuplicationPopup} + secondaryIssueLocations={secondaryIssueLocations} + onLineMouseEnter={onLineMouseEnter} + onLineMouseLeave={onLineMouseLeave} + > + {props.renderAdditionalChildInLine?.(line)} + + ); + })} + + + {(!lastLine || snippet[snippet.length - 1].line < lastLine) && ( + + + + )} + +
    + ); } export default withTheme(SnippetViewer); diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx index cdbcdb84b88..df138d37b67 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/SnippetViewer-test.tsx @@ -22,7 +22,6 @@ import { range } from 'lodash'; import * as React from 'react'; import { byRole } from 'testing-library-selector'; import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/mocks/sources'; -import { mockIssue } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import SnippetViewer, { SnippetViewerProps } from '../SnippetViewer'; @@ -102,8 +101,6 @@ function renderSnippetViewer(props: Partial = {}) { highlightedLocationMessage={{ index: 0, text: '' }} highlightedSymbols={[]} index={0} - issue={mockIssue()} - lastSnippetOfLastGroup={false} loadDuplications={jest.fn()} locations={[]} locationsByLine={{}} diff --git a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts index 10287fb3267..f84d5db25b0 100644 --- a/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts +++ b/server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts @@ -17,6 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { isDefined } from '../../../helpers/types'; import { ComponentQualifier } from '../../../types/component'; import { ExpandDirection, @@ -134,18 +135,36 @@ export function createSnippets(params: { return hasSecondaryLocations ? ranges.sort((a, b) => a.start - b.start) : ranges; } +function decorateWithUnderlineFlags(line: SourceLine, sourcesMap: LineMap) { + const previousLine = sourcesMap[line.line - 1]; + const decoratedLine = { ...line }; + if (isDefined(line.coverageStatus)) { + decoratedLine.coverageBlock = + line.coverageStatus === previousLine?.coverageStatus ? previousLine.coverageBlock : line.line; + } + if (line.isNew) { + decoratedLine.newCodeBlock = previousLine?.isNew ? previousLine.newCodeBlock : line.line; + } + return decoratedLine; +} + export function linesForSnippets(snippets: Snippet[], componentLines: LineMap) { - return snippets - .map((snippet) => { - const lines = []; - for (let i = snippet.start; i <= snippet.end; i++) { - if (componentLines[i]) { - lines.push(componentLines[i]); - } + return snippets.reduce>((acc, snippet) => { + const snippetSources = []; + const snippetSourcesMap: LineMap = {}; + for (let idx = snippet.start; idx <= snippet.end; idx++) { + if (isDefined(componentLines[idx])) { + const line = decorateWithUnderlineFlags(componentLines[idx], snippetSourcesMap); + snippetSourcesMap[line.line] = line; + snippetSources.push(line); } - return lines; - }) - .filter((snippet) => snippet.length > 0); + } + + if (snippetSources.length > 0) { + acc.push({ snippet: snippetSources, sourcesMap: snippetSourcesMap }); + } + return acc; + }, []); } export function groupLocationsByComponent( 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 8f09fcc0e4b..086527df353 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 @@ -302,7 +302,7 @@ describe('navigation', () => { expect(ui.fixContent.get()).toBeInTheDocument(); await user.click(ui.codeTab.get()); - expect(ui.codeContent.get()).toHaveClass('source-table'); + expect(ui.codeContent.get()).toBeInTheDocument(); }); it('should be able to navigate the hotspot list with keyboard', async () => { diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx index 78a329c7ddd..3434874b0d7 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx @@ -169,8 +169,6 @@ export default function HotspotSnippetContainerRenderer( highlightedLocationMessage={highlightedLocation} highlightedSymbols={highlightedSymbols} index={0} - issue={hotspot} - lastSnippetOfLastGroup={false} locations={secondaryLocations} locationsByLine={primaryLocations} onLocationSelect={props.onLocationSelect} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx index e4785151980..3f813f76da8 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx @@ -42,6 +42,9 @@ import { } from '../../types/types'; import { Alert } from '../ui/Alert'; import { WorkspaceContext } from '../workspace/context'; +import SourceViewerCode from './SourceViewerCode'; +import { SourceViewerContext } from './SourceViewerContext'; +import SourceViewerHeader from './SourceViewerHeader'; import DuplicationPopup from './components/DuplicationPopup'; import { filterDuplicationBlocksByLine, @@ -57,9 +60,6 @@ import { } from './helpers/indexing'; import { LINES_TO_LOAD } from './helpers/lines'; import loadIssues from './helpers/loadIssues'; -import SourceViewerCode from './SourceViewerCode'; -import { SourceViewerContext } from './SourceViewerContext'; -import SourceViewerHeader from './SourceViewerHeader'; import './styles.css'; export interface Props { @@ -487,10 +487,11 @@ export default class SourceViewer extends React.PureComponent { )} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx index f2a24ae873b..7d473ae4de0 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx @@ -17,6 +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 { SonarCodeColorizer } from 'design-system/lib'; +import { noop } from 'lodash'; import * as React from 'react'; import { Button } from '../../components/controls/buttons'; import { translate } from '../../helpers/l10n'; @@ -152,6 +154,10 @@ export default class SourceViewerCode extends React.PureComponent { return ( { issueLocations={this.getIssueLocationsForLine(line)} issues={issuesForLine} key={line.line || line.code} - last={index === this.props.sources.length - 1 && !this.props.hasSourcesAfter} line={line} loadDuplications={this.props.loadDuplications} onIssueSelect={this.props.onIssueSelect} @@ -217,63 +222,71 @@ export default class SourceViewerCode extends React.PureComponent { const hasFileIssues = displayIssues && issues.some((issue) => !issue.textRange); return ( -
    - {this.props.hasSourcesBefore && ( -
    - {this.props.loadingSourcesBefore ? ( -
    - - - {translate('source_viewer.loading_more_code')} - -
    - ) : ( - - )} -
    - )} + +
    + {this.props.hasSourcesBefore && ( +
    + {this.props.loadingSourcesBefore ? ( +
    + + + {translate('source_viewer.loading_more_code')} + +
    + ) : ( + + )} +
    + )} - - - {hasFileIssues && - this.renderLine({ - line: ZERO_LINE, - index: -1, - displayCoverage, - displayDuplications, - displayIssues, - })} - {sources.map((line, index) => - this.renderLine({ line, index, displayCoverage, displayDuplications, displayIssues }) - )} - -
    + + + {hasFileIssues && + this.renderLine({ + line: ZERO_LINE, + index: -1, + displayCoverage, + displayDuplications, + displayIssues, + })} + {sources.map((line, index) => + this.renderLine({ + line, + index, + displayCoverage, + displayDuplications, + displayIssues, + }) + )} + +
    - {this.props.hasSourcesAfter && ( -
    - {this.props.loadingSourcesAfter ? ( -
    - - - {translate('source_viewer.loading_more_code')} - -
    - ) : ( - - )} -
    - )} -
    + {this.props.hasSourcesAfter && ( +
    + {this.props.loadingSourcesAfter ? ( +
    + + + {translate('source_viewer.loading_more_code')} + +
    + ) : ( + + )} +
    + )} +
    + ); } } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx index 541ab1cfcb3..865ed68ea6a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerContext.tsx @@ -30,3 +30,7 @@ export const SourceViewerContext = React.createContext branchLike: {} as BranchLike, file: {} as SourceViewerFile, }); + +export function useSourceViewerContext() { + return React.useContext(SourceViewerContext); +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx index 1439f083790..4fe7c105ac2 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx @@ -27,8 +27,8 @@ import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import { HttpStatus } from '../../../helpers/request'; import { mockIssue } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; -import loadIssues from '../helpers/loadIssues'; import SourceViewer from '../SourceViewer'; +import loadIssues from '../helpers/loadIssues'; jest.mock('../../../api/components'); jest.mock('../../../api/issues'); @@ -110,8 +110,8 @@ it('should show a permalink on line number', async () => { }); expect( - lowerRowScreen.getByRole('button', { - name: 'component_viewer.copy_permalink', + lowerRowScreen.getByRole('menuitem', { + name: 'source_viewer.copy_permalink', }) ).toBeInTheDocument(); }); @@ -204,15 +204,10 @@ it('should show SCM information', async () => { }) ); - expect( - await firstRowScreen.findByRole('heading', { level: 4, name: 'author' }) - ).toBeInTheDocument(); - expect( - firstRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.commited_on' }) - ).toBeInTheDocument(); - expect( - firstRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' }) - ).toBeInTheDocument(); + // After using miui component the tooltip is appearing outside of the row + expect(await screen.findAllByText('author')).toHaveLength(4); + expect(screen.getAllByText('source_viewer.tooltip.scm.commited_on')).toHaveLength(3); + expect(screen.getAllByText('source_viewer.tooltip.scm.revision')).toHaveLength(7); row = screen.getByRole('row', { name: /\* SonarQube$/ }); expect(row).toBeInTheDocument(); @@ -225,48 +220,26 @@ it('should show SCM information', async () => { row = await screen.findByRole('row', { name: /\* mailto:info AT sonarsource DOT com$/ }); expect(row).toBeInTheDocument(); const fourthRowScreen = within(row); - await user.click( - fourthRowScreen.getByRole('button', { - name: 'source_viewer.author_X.stas.vilchik@sonarsource.com, source_viewer.click_for_scm_info.4', - }) - ); - - expect( - await fourthRowScreen.findByRole('heading', { level: 4, name: 'author' }) - ).toBeInTheDocument(); - expect( - fourthRowScreen.queryByRole('heading', { - level: 4, - name: 'source_viewer.tooltip.scm.commited_on', - }) - ).not.toBeInTheDocument(); - expect( - fourthRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' }) - ).toBeInTheDocument(); + await act(async () => { + await user.click( + fourthRowScreen.getByRole('button', { + name: 'source_viewer.author_X.stas.vilchik@sonarsource.com, source_viewer.click_for_scm_info.4', + }) + ); + }); // SCM with no date no author row = await screen.findByRole('row', { name: /\* 5$/ }); expect(row).toBeInTheDocument(); const fithRowScreen = within(row); expect(fithRowScreen.getByText('…')).toBeInTheDocument(); - await user.click( - fithRowScreen.getByRole('button', { - name: 'source_viewer.click_for_scm_info.5', - }) - ); - - expect( - fithRowScreen.queryByRole('heading', { level: 4, name: 'author' }) - ).not.toBeInTheDocument(); - expect( - fithRowScreen.queryByRole('heading', { - level: 4, - name: 'source_viewer.tooltip.scm.commited_on', - }) - ).not.toBeInTheDocument(); - expect( - fithRowScreen.getByRole('heading', { level: 4, name: 'source_viewer.tooltip.scm.revision' }) - ).toBeInTheDocument(); + await act(async () => { + await user.click( + fithRowScreen.getByRole('button', { + name: 'source_viewer.click_for_scm_info.5', + }) + ); + }); // No SCM Popup row = await screen.findByRole('row', { @@ -369,16 +342,19 @@ it('should show duplication block', async () => { duplicateLine.getByLabelText('source_viewer.tooltip.duplicated_block') ).toBeInTheDocument(); - await user.click( - duplicateLine.getByRole('button', { name: 'source_viewer.tooltip.duplicated_block' }) - ); + await act(async () => { + await user.click( + duplicateLine.getByRole('button', { name: 'source_viewer.tooltip.duplicated_block' }) + ); + }); - expect(duplicateLine.getAllByRole('link', { name: 'foo:test2.js' })[0]).toBeInTheDocument(); + expect(screen.getByRole('tooltip')).toBeVisible(); await act(async () => { - await user.keyboard('[Escape]'); + await user.click(document.body); }); - expect(duplicateLine.queryByRole('link', { name: 'foo:test2.js' })).not.toBeInTheDocument(); + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); }); it('should highlight symbol', async () => { diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx index d69d6418d05..ca1c33e6722 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/DuplicationPopup.tsx @@ -17,16 +17,21 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { + DiscreetLink, + DuplicationHighlight, + FlagMessage, + StandoutLink as Link, + QualifierIcon, +} from 'design-system'; import { groupBy, sortBy } from 'lodash'; -import * as React from 'react'; -import Link from '../../../components/common/Link'; -import QualifierIcon from '../../../components/icons/QualifierIcon'; -import { Alert } from '../../../components/ui/Alert'; +import React, { Fragment, PureComponent } from 'react'; import { isPullRequest } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; import { collapsedDirFromPath, fileFromPath } from '../../../helpers/path'; import { getProjectUrl } from '../../../helpers/urls'; import { BranchLike } from '../../../types/branch-like'; +import { ComponentQualifier } from '../../../types/component'; import { Dict, DuplicatedFile, DuplicationBlock, SourceViewerFile } from '../../../types/types'; import { WorkspaceContextShape } from '../../workspace/context'; @@ -35,11 +40,12 @@ interface Props { branchLike: BranchLike | undefined; duplicatedFiles?: Dict; inRemovedComponent: boolean; + duplicationHeader: string; openComponent: WorkspaceContextShape['openComponent']; sourceViewerFile: SourceViewerFile; } -export default class DuplicationPopup extends React.PureComponent { +export default class DuplicationPopup extends PureComponent { shouldLink() { const { branchLike } = this.props; return !isPullRequest(branchLike); @@ -64,22 +70,27 @@ export default class DuplicationPopup extends React.PureComponent { renderDuplication(file: DuplicatedFile, children: React.ReactNode, line?: number) { return this.shouldLink() ? ( - {children} - + ) : ( children ); } render() { - const { duplicatedFiles = {}, sourceViewerFile } = this.props; + const { + duplicatedFiles = {}, + sourceViewerFile, + duplicationHeader, + inRemovedComponent, + } = this.props; const groupedBlocks = groupBy(this.props.blocks, '_ref'); let duplications = Object.keys(groupedBlocks).map((fileRef) => { @@ -90,7 +101,6 @@ export default class DuplicationPopup extends React.PureComponent { }); // first duplications in the same file - // then duplications in the same sub-project // then duplications in the same project // then duplications in other projects duplications = sortBy( @@ -100,23 +110,24 @@ export default class DuplicationPopup extends React.PureComponent { ); return ( -
    - {this.props.inRemovedComponent && ( - +
    + {inRemovedComponent && ( + {translate('duplications.dups_found_on_deleted_resource')} - + )} {duplications.length > 0 && ( <> -
    - {translate('component_viewer.transition.duplication')} -
    + {duplicationHeader} {duplications.map((duplication) => ( -
    -
    +
    +
    {this.isDifferentComponent(duplication.file, this.props.sourceViewerFile) && ( -
    - +
    + {duplication.file.projectName} @@ -124,23 +135,21 @@ export default class DuplicationPopup extends React.PureComponent { )} {duplication.file.key !== this.props.sourceViewerFile.key && ( -
    +
    {this.renderDuplication( duplication.file, <> {collapsedDirFromPath(duplication.file.name)} - - {fileFromPath(duplication.file.name)} - + {fileFromPath(duplication.file.name)} )}
    )} -
    +
    {'Lines: '} {duplication.blocks.map((block, index) => ( - + {this.renderDuplication( duplication.file, <> @@ -151,7 +160,7 @@ export default class DuplicationPopup extends React.PureComponent { block.from )} {index < duplication.blocks.length - 1 && ', '} - + ))}
    diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx index 7d3d48fd7be..e804c11b9df 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx @@ -18,18 +18,21 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import classNames from 'classnames'; +import { LineCoverage, LineMeta, LineNumber, LineWrapper } from 'design-system'; import { times } from 'lodash'; import * as React from 'react'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { getCodeUrl, getPathUrlAsString } from '../../../helpers/urls'; import { Issue, LinearIssueLocation, SourceLine } from '../../../types/types'; +import { useSourceViewerContext } from '../SourceViewerContext'; import './Line.css'; -import LineCode from './LineCode'; -import LineCoverage from './LineCoverage'; +import { LineCode } from './LineCode'; import LineDuplicationBlock from './LineDuplicationBlock'; import LineIssuesIndicator from './LineIssuesIndicator'; -import LineNumber from './LineNumber'; +import LineOptionsPopup from './LineOptionsPopup'; import LineSCM from './LineSCM'; -interface Props { +export interface LineProps { children?: React.ReactNode; displayAllIssues?: boolean; displayCoverage: boolean; @@ -46,7 +49,6 @@ interface Props { highlightedSymbols: string[] | undefined; issueLocations: LinearIssueLocation[]; issues: Issue[]; - last: boolean; line: SourceLine; loadDuplications: (line: SourceLine) => void; onIssuesClose: (line: SourceLine) => void; @@ -60,127 +62,182 @@ interface Props { renderDuplicationPopup: (index: number, line: number) => React.ReactNode; scrollToUncoveredLine?: boolean; secondaryIssueLocations: LinearIssueLocation[]; - verticalBuffer?: number; + onLineMouseEnter: (line: number) => void; + onLineMouseLeave: (line: number) => void; + displayCoverageUnderline: boolean; + displayNewCodeUnderline: boolean; } -const LINE_HEIGHT = 18; +export default function Line(props: LineProps) { + const { + children, + displayAllIssues, + displayCoverage, + displayDuplications, + displayLineNumberOptions, + displayLocationMarkers, + highlightedLocationMessage, + displayNewCodeUnderline, + displayIssues, + displaySCM = true, + duplications, + duplicationsCount, + firstLineNumber, + highlighted, + highlightedSymbols, + issueLocations, + issues, + line, + openIssues, + previousLine, + scrollToUncoveredLine, + secondaryIssueLocations, + displayCoverageUnderline, + onLineMouseEnter, + onLineMouseLeave, + } = props; -export default class Line extends React.PureComponent { - handleIssuesIndicatorClick = () => { - if (this.props.openIssues) { - this.props.onIssuesClose(this.props.line); - this.props.onIssueUnselect(); + const handleIssuesIndicatorClick = () => { + if (props.openIssues) { + props.onIssuesClose(props.line); + props.onIssueUnselect(); } else { - this.props.onIssuesOpen(this.props.line); + props.onIssuesOpen(props.line); - const { issues } = this.props; + const { issues } = props; if (issues.length > 0) { - this.props.onIssueSelect(issues[0].key); + props.onIssueSelect(issues[0].key); } } }; - render() { - const { - children, - displayAllIssues, - displayCoverage, - displayDuplications, - displayLineNumberOptions, - displayLocationMarkers, - highlightedLocationMessage, - displayIssues, - displaySCM = true, - duplications, - duplicationsCount, - firstLineNumber, - highlighted, - highlightedSymbols, - issueLocations, - issues, - last, - line, - openIssues, - previousLine, - scrollToUncoveredLine, - secondaryIssueLocations, - verticalBuffer, - } = this.props; - - const className = classNames('source-line', { - 'source-line-highlighted': highlighted, - 'source-line-filtered': line.isNew, - 'source-line-filtered-dark': - displayCoverage && - (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered'), - 'source-line-last': last === true, - }); - - const bottomPadding = verticalBuffer ? verticalBuffer * LINE_HEIGHT : undefined; - const blocksLoaded = duplicationsCount > 0; - - // default is true - const displayOptions = displayLineNumberOptions !== false; - return ( - - - - {displaySCM && } - {displayIssues && !displayAllIssues ? ( - - ) : ( - - )} - - {displayDuplications && ( - - )} - - {blocksLoaded && - times(duplicationsCount - 1, (index) => { - return ( - - ); - })} - - {displayCoverage && ( - - )} - - 0; + + const handleLineMouseEnter = React.useCallback( + () => onLineMouseEnter(line.line), + [line.line, onLineMouseEnter] + ); + + const handleLineMouseLeave = React.useCallback( + () => onLineMouseLeave(line.line), + [line.line, onLineMouseLeave] + ); + + // default is true + const displayOptions = displayLineNumberOptions !== false; + + const { branchLike, file } = useSourceViewerContext(); + const permalink = getPathUrlAsString( + getCodeUrl(file.project, branchLike, file.key, line.line), + false + ); + + const getStatusTooltip = (line: SourceLine) => { + switch (line.coverageStatus) { + case 'uncovered': + return line.conditions + ? translateWithParameters('source_viewer.tooltip.uncovered.conditions', line.conditions) + : translate('source_viewer.tooltip.uncovered'); + case 'covered': + return line.conditions + ? translateWithParameters('source_viewer.tooltip.covered.conditions', line.conditions) + : translate('source_viewer.tooltip.covered'); + case 'partially-covered': + return line.conditions + ? translateWithParameters( + 'source_viewer.tooltip.partially-covered.conditions', + line.coveredConditions ?? 0, + line.conditions + ) + : translate('source_viewer.tooltip.partially-covered'); + default: + return undefined; + } + }; + + const status = getStatusTooltip(line); + + return ( + + } + /> + + {displaySCM && } + {displayIssues && !displayAllIssues ? ( + - {children} - - - ); - } + onClick={handleIssuesIndicatorClick} + /> + ) : ( + + )} + + {displayDuplications && ( + + )} + + {blocksLoaded && + times(duplicationsCount - 1, (index) => { + return ( + + ); + })} + + {displayCoverage && ( + + )} + + {children} + + + ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx index 87745243401..1976957c122 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCode.tsx @@ -17,35 +17,42 @@ * 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 * as React from 'react'; +import { + CoveredUnderline, + CoveredUnderlineLabel, + LineCodeLayer, + LineCodeLayers, + LineCodePreFormatted, + LineMarker, + LineToken, + NewCodeUnderline, + NewCodeUnderlineLabel, + UncoveredUnderline, + UncoveredUnderlineLabel, + UnderlineLabels, +} from 'design-system'; +import React, { PureComponent, ReactNode } from 'react'; import { IssueSourceViewerScrollContext } from '../../../apps/issues/components/IssueSourceViewerScrollContext'; -import { MessageFormatting } from '../../../types/issues'; +import { translate } from '../../../helpers/l10n'; import { LinearIssueLocation, SourceLine } from '../../../types/types'; -import LocationIndex from '../../common/LocationIndex'; -import Tooltip from '../../controls/Tooltip'; -import { IssueMessageHighlighting } from '../../issue/IssueMessageHighlighting'; -import { - highlightIssueLocations, - highlightSymbol, - splitByTokens, - Token, -} from '../helpers/highlight'; +import { Token, getHighlightedTokens } from '../helpers/highlight'; interface Props { - className?: string; + displayCoverageUnderline?: boolean; displayLocationMarkers?: boolean; + displayNewCodeUnderlineLabel?: boolean; + hideLocationIndex?: boolean; highlightedLocationMessage: { index: number; text: string | undefined } | undefined; highlightedSymbols: string[] | undefined; issueLocations: LinearIssueLocation[]; line: SourceLine; onLocationSelect: ((index: number) => void) | undefined; - onSymbolClick: (symbols: Array) => void; - padding?: number; + onSymbolClick: (symbols: string[]) => void; + previousLine?: SourceLine; secondaryIssueLocations: LinearIssueLocation[]; } -export default class LineCode extends React.PureComponent> { +export class LineCode extends PureComponent> { symbols?: NodeListOf; nodeNodeRef = (el: HTMLElement | null) => { @@ -83,9 +90,46 @@ export default class LineCode extends React.PureComponent { + const { highlightedLocationMessage, secondaryIssueLocations, hideLocationIndex } = this.props; + const selected = + highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker; + const loc = secondaryIssueLocations.find((loc) => loc.index === marker); + const message = loc?.text; + const isLeading = leadingMarker && markerIndex === 0; + return ( + + {(ctx) => ( + + )} + + ); + }; + + addLineToken = (token: Token, index: number) => { + return ( + 0} + key={`${token.text}-${index}`} + {...token.modifiers} + > + {token.text} + + ); + }; + + renderTokens = (tokens: Token[]) => { + const renderedTokens: ReactNode[] = []; // track if the first marker is displayed before the source code // set `false` for the first token in a row @@ -93,122 +137,87 @@ export default class LineCode extends React.PureComponent { if (this.props.displayLocationMarkers && token.markers.length > 0) { - token.markers.forEach((marker) => { - const selected = - highlightedLocationMessage !== undefined && highlightedLocationMessage.index === marker; - const loc = secondaryIssueLocations.find((loc) => loc.index === marker); - const message = loc?.text; - const messageFormattings = loc?.textFormatting; - renderedTokens.push( - this.renderMarker(marker, message, messageFormattings, selected, leadingMarker) - ); + token.markers.forEach((marker, markerIndex) => { + renderedTokens.push(this.addLineMarker(marker, index, leadingMarker, markerIndex)); }); } - renderedTokens.push( - // eslint-disable-next-line react/no-array-index-key - - {token.text} - - ); + + renderedTokens.push(this.addLineToken(token, index)); // keep leadingMarker truthy if previous token has only whitespaces leadingMarker = (index === 0 ? true : leadingMarker) && !token.text.trim().length; }); - return renderedTokens; - } - - renderMarker( - index: number, - message: string | undefined, - messageFormattings: MessageFormatting[] | undefined, - selected: boolean, - leading: boolean - ) { - const { onLocationSelect } = this.props; - const onClick = onLocationSelect ? () => onLocationSelect(index) : undefined; - return ( - - } - placement="top" - > - - - {(ctx) => ( - - {index + 1} - - )} - - - - ); - } + return renderedTokens; + }; render() { const { + displayCoverageUnderline, + displayNewCodeUnderlineLabel, children, - className, highlightedLocationMessage, highlightedSymbols, issueLocations, line, - padding, + previousLine, secondaryIssueLocations, } = this.props; - const container = document.createElement('div'); - container.innerHTML = this.props.line.code || ''; - - let tokens = splitByTokens(container.childNodes); - - if (highlightedSymbols) { - highlightedSymbols.forEach((symbol) => { - tokens = highlightSymbol(tokens, symbol); - }); - } - - if (issueLocations.length > 0) { - tokens = highlightIssueLocations(tokens, issueLocations); - } - - if (secondaryIssueLocations) { - tokens = highlightIssueLocations(tokens, secondaryIssueLocations, 'issue-location'); - - if (highlightedLocationMessage) { - const location = secondaryIssueLocations.find( - (location) => location.index === highlightedLocationMessage.index - ); - if (location) { - tokens = highlightIssueLocations(tokens, [location], 'selected'); - } - } - } - - const renderedTokens = this.renderToken(tokens); - - const style = padding ? { paddingBottom: `${padding}px` } : undefined; + const displayCoverageUnderlineLabel = + displayCoverageUnderline && line.coverageBlock === line.line; + const previousLineHasUnderline = + previousLine?.isNew || + (previousLine?.coverageStatus && previousLine.coverageBlock === line.coverageBlock); return ( - -
    -
    {renderedTokens}
    -
    - - {children} - + {(displayCoverageUnderlineLabel || displayNewCodeUnderlineLabel) && ( + + {displayCoverageUnderlineLabel && line.coverageStatus === 'covered' && ( + + {translate('source_viewer.coverage.covered')} + + )} + {displayCoverageUnderlineLabel && + (line.coverageStatus === 'uncovered' || + line.coverageStatus === 'partially-covered') && ( + + {translate('source_viewer.coverage', line.coverageStatus)} + + )} + {displayNewCodeUnderlineLabel && ( + {translate('source_viewer.new_code')} + )} + + )} + {line.isNew && } + {displayCoverageUnderline && line.coverageStatus === 'covered' && ( + + )} + {displayCoverageUnderline && + (line.coverageStatus === 'uncovered' || line.coverageStatus === 'partially-covered') && ( + + )} + + + + {this.renderTokens( + getHighlightedTokens({ + code: line.code, + highlightedLocationMessage, + highlightedSymbols, + issueLocations, + secondaryIssueLocations, + }) + )} + + {children} + + ); } } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx deleted file mode 100644 index 55bf4b7879c..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineCoverage.tsx +++ /dev/null @@ -1,79 +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 * as React from 'react'; -import Tooltip from '../../../components/controls/Tooltip'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { SourceLine } from '../../../types/types'; - -export interface LineCoverageProps { - line: SourceLine; - scrollToUncoveredLine?: boolean; -} - -export function LineCoverage({ line, scrollToUncoveredLine }: LineCoverageProps) { - const coverageMarker = React.useRef(null); - React.useEffect(() => { - if (scrollToUncoveredLine && coverageMarker.current) { - coverageMarker.current.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center', - }); - } - }, [scrollToUncoveredLine, coverageMarker]); - - const className = - 'source-meta source-line-coverage' + - (line.coverageStatus != null ? ` source-line-${line.coverageStatus}` : ''); - const status = getStatusTooltip(line); - - return ( - - -
    - - - ); -} - -function getStatusTooltip(line: SourceLine) { - switch (line.coverageStatus) { - case 'uncovered': - return line.conditions - ? translateWithParameters('source_viewer.tooltip.uncovered.conditions', line.conditions) - : translate('source_viewer.tooltip.uncovered'); - case 'covered': - return line.conditions - ? translateWithParameters('source_viewer.tooltip.covered.conditions', line.conditions) - : translate('source_viewer.tooltip.covered'); - case 'partially-covered': - return line.conditions - ? translateWithParameters( - 'source_viewer.tooltip.partially-covered.conditions', - line.coveredConditions || 0, - line.conditions - ) - : translate('source_viewer.tooltip.partially-covered'); - default: - return undefined; - } -} - -export default React.memo(LineCoverage); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx index 86d2fde8b8c..da2c306f5ab 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx @@ -17,15 +17,11 @@ * 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 { DuplicationBlock, LineMeta, OutsideClickHandler, PopupPlacement } from 'design-system'; import * as React from 'react'; -import { DropdownOverlay } from '../../../components/controls/Dropdown'; -import Toggler from '../../../components/controls/Toggler'; import Tooltip from '../../../components/controls/Tooltip'; -import { PopupPlacement } from '../../../components/ui/popups'; import { translate } from '../../../helpers/l10n'; import { SourceLine } from '../../../types/types'; -import { ButtonPlain } from '../../controls/buttons'; export interface LineDuplicationBlockProps { blocksLoaded: boolean; @@ -37,46 +33,49 @@ export interface LineDuplicationBlockProps { } export function LineDuplicationBlock(props: LineDuplicationBlockProps) { - const { blocksLoaded, duplicated, index, line } = props; - const [dropdownOpen, setDropdownOpen] = React.useState(false); + const { blocksLoaded, duplicated, index, line, onClick } = props; + const [popupOpen, setPopupOpen] = React.useState(false); - const className = classNames('source-meta', 'source-line-duplications', { - 'source-line-duplicated': duplicated, - }); + const tooltip = popupOpen ? undefined : translate('source_viewer.tooltip.duplicated_block'); - const tooltip = dropdownOpen ? undefined : translate('source_viewer.tooltip.duplicated_block'); + const handleClick = React.useCallback(() => { + setPopupOpen(!popupOpen); + if (!blocksLoaded && line.duplicated && onClick) { + onClick(line); + } + }, [blocksLoaded, line, onClick, popupOpen]); + + const handleClose = React.useCallback(() => setPopupOpen(false), []); return duplicated ? ( - - -
    - setDropdownOpen(false)} - open={dropdownOpen} - overlay={ - - {props.renderDuplicationPopup(index, line.line)} - - } + + + + - { - setDropdownOpen(true); - if (!blocksLoaded && line.duplicated && props.onClick) { - props.onClick(line); - } - }} + aria-expanded={popupOpen} + aria-haspopup="dialog" + onClick={handleClick} + role="button" + tabIndex={0} /> - -
    -
    - + + + + ) : ( - -
    - + ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx index f2cf3b718b6..6a6bdcfaa0a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx @@ -17,15 +17,15 @@ * 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 { IssueIndicatorButton, LineIssuesIndicatorIcon, LineMeta } from 'design-system'; import { uniq } from 'lodash'; import * as React from 'react'; import Tooltip from '../../../components/controls/Tooltip'; -import IssueIcon from '../../../components/icons/IssueIcon'; import { sortByType } from '../../../helpers/issues'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Issue, SourceLine } from '../../../types/types'; -import { ButtonPlain } from '../../controls/buttons'; + +const MOUSE_LEAVE_DELAY = 0.25; export interface LineIssuesIndicatorProps { issues: Issue[]; @@ -37,12 +37,9 @@ export interface LineIssuesIndicatorProps { export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { const { issues, issuesOpen, line } = props; const hasIssues = issues.length > 0; - const className = classNames('source-meta', 'source-line-issues', { - 'source-line-with-issues': hasIssues, - }); if (!hasIssues) { - return ; + return ; } const mostImportantIssue = sortByType(issues)[0]; @@ -71,14 +68,20 @@ export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { } return ( - - - - - {issues.length > 1 && {issues.length}} - + + + + + - + ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx deleted file mode 100644 index eb1e4d9e912..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx +++ /dev/null @@ -1,65 +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 * as React from 'react'; -import Toggler from '../../../components/controls/Toggler'; -import { translateWithParameters } from '../../../helpers/l10n'; -import { SourceLine } from '../../../types/types'; -import { ButtonPlain } from '../../controls/buttons'; -import LineOptionsPopup from './LineOptionsPopup'; - -export interface LineNumberProps { - displayOptions: boolean; - firstLineNumber: number; - line: SourceLine; -} - -export function LineNumber({ displayOptions, firstLineNumber, line }: LineNumberProps) { - const [isOpen, setOpen] = React.useState(false); - const { line: lineNumber } = line; - const hasLineNumber = !!lineNumber; - - return hasLineNumber ? ( - - {displayOptions ? ( - setOpen(false)} - open={isOpen} - overlay={} - > - setOpen(true)} - > - {lineNumber} - - - ) : ( - lineNumber - )} - - ) : ( - - ); -} - -export default React.memo(LineNumber); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx index e3c79711441..a647b8b0c5a 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineOptionsPopup.tsx @@ -17,47 +17,38 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; -import { DropdownOverlay } from '../../../components/controls/Dropdown'; -import { PopupPlacement } from '../../../components/ui/popups'; +import { DropdownMenu, ItemCopy } from 'design-system'; +import React, { memo } from 'react'; import { translate } from '../../../helpers/l10n'; -import { getCodeUrl, getPathUrlAsString } from '../../../helpers/urls'; import { SourceLine } from '../../../types/types'; -import { ClipboardButton } from '../../controls/clipboard'; -import { SourceViewerContext } from '../SourceViewerContext'; +import { getLineCodeAsPlainText } from '../helpers/lines'; -export interface LineOptionsPopupProps { - firstLineNumber: number; +interface Props { line: SourceLine; + permalink: string; } -export function LineOptionsPopup({ firstLineNumber, line }: LineOptionsPopupProps) { +export function LineOptionsPopup({ line, permalink }: Props) { + const lineCodeAsPlainText = getLineCodeAsPlainText(line.code); return ( - - {({ branchLike, file }) => { - const codeLocation = getCodeUrl(file.project, branchLike, file.key, line.line); - const codeUrl = getPathUrlAsString(codeLocation, false); - const isAtTop = line.line - 4 < firstLineNumber; - return ( - -
    - - {translate('component_viewer.copy_permalink')} - -
    -
    - ); - }} -
    + + + {translate('source_viewer.copy_permalink')} + + + {lineCodeAsPlainText && ( + + {translate('source_viewer.copy_line')} + + )} + ); } -export default React.memo(LineOptionsPopup); +export default memo(LineOptionsPopup); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx index b9e31b605ad..5dd9ec882de 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx @@ -17,48 +17,67 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import * as React from 'react'; -import Dropdown from '../../../components/controls/Dropdown'; -import { PopupPlacement } from '../../../components/ui/popups'; +import { + LineMeta, + LineSCMStyled, + LineSCMStyledDiv, + OutsideClickHandler, + PopupPlacement, +} from 'design-system'; +import React, { memo, useCallback, useState } from 'react'; import { translateWithParameters } from '../../../helpers/l10n'; import { SourceLine } from '../../../types/types'; -import { ButtonPlain } from '../../controls/buttons'; +import Tooltip from '../../controls/Tooltip'; import SCMPopup from './SCMPopup'; -export interface LineSCMProps { +interface Props { line: SourceLine; previousLine: SourceLine | undefined; } -export function LineSCM({ line, previousLine }: LineSCMProps) { - const hasPopup = !!line.line; - const cell = ( -
    - {isSCMChanged(line, previousLine) ? line.scmAuthor || '…' : ' '} -
    - ); +function LineSCM({ line, previousLine }: Props) { + const [isOpen, setIsOpen] = useState(false); - if (hasPopup) { - let ariaLabel = translateWithParameters('source_viewer.click_for_scm_info', line.line); - if (line.scmAuthor) { - ariaLabel = `${translateWithParameters( - 'source_viewer.author_X', - line.scmAuthor - )}, ${ariaLabel}`; - } + const handleToggle = useCallback(() => { + setIsOpen(!isOpen); + }, [isOpen]); + const handleClose = useCallback(() => { + setIsOpen(false); + }, []); + const isFileIssue = !line.line; + if (isFileIssue) { return ( - - } overlayPlacement={PopupPlacement.RightTop}> - {cell} - - + + {line.scmAuthor ?? ' '} + ); } + + let ariaLabel = translateWithParameters('source_viewer.click_for_scm_info', line.line); + if (line.scmAuthor) { + ariaLabel = `${translateWithParameters( + 'source_viewer.author_X', + line.scmAuthor + )}, ${ariaLabel}`; + } + return ( - - {cell} - + + + } + placement={PopupPlacement.Right} + visible={isOpen} + isInteractive={true} + classNameInner="sw-max-w-abs-600" + > + + {isSCMChanged(line, previousLine) ? line.scmAuthor ?? '…' : ' '} + + + + ); } @@ -70,4 +89,4 @@ function isSCMChanged(s: SourceLine, p: SourceLine | undefined) { return changed; } -export default React.memo(LineSCM); +export default memo(LineSCM); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx index cd2f934bd83..5d1272abc2e 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/SCMPopup.tsx @@ -17,41 +17,43 @@ * 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 * as React from 'react'; +import { SCMHighlight } from 'design-system'; +import React, { memo } from 'react'; import { translate } from '../../../helpers/l10n'; import { SourceLine } from '../../../types/types'; import DateFormatter from '../../intl/DateFormatter'; -export interface SCMPopupProps { +interface Props { line: SourceLine; } -export function SCMPopup({ line }: SCMPopupProps) { +export function SCMPopup({ line }: Props) { const hasAuthor = line.scmAuthor !== undefined && line.scmAuthor !== ''; const hasDate = line.scmDate !== undefined; return ( -
    +
    {hasAuthor && ( -
    -

    {translate('author')}

    - {line.scmAuthor} +
    + {translate('author')} +
    {line.scmAuthor}
    )} {hasDate && ( -
    -

    {translate('source_viewer.tooltip.scm.commited_on')}

    - +
    + {translate('source_viewer.tooltip.scm.commited_on')} +
    + +
    )} {line.scmRevision && ( -
    -

    {translate('source_viewer.tooltip.scm.revision')}

    - {line.scmRevision} +
    + {translate('source_viewer.tooltip.scm.revision')} +
    {line.scmRevision}
    )}
    ); } -export default React.memo(SCMPopup); +export default memo(SCMPopup); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx deleted file mode 100644 index 9941ae567ce..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/Line-test.tsx +++ /dev/null @@ -1,96 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { mockSourceLine } from '../../../../helpers/mocks/sources'; -import { mockIssue } from '../../../../helpers/testMocks'; -import Line from '../Line'; - -it('should render correctly for last, new, and highlighted lines', () => { - expect( - shallowRender({ - highlighted: true, - last: true, - line: mockSourceLine({ isNew: true }), - }) - ).toMatchSnapshot(); -}); - -it('handles the opening and closing of issues', () => { - const line = mockSourceLine(); - const issue = mockIssue(); - const onIssuesClose = jest.fn(); - const onIssueUnselect = jest.fn(); - const onIssuesOpen = jest.fn(); - const onIssueSelect = jest.fn(); - const wrapper = shallowRender({ - issues: [issue], - line, - onIssuesClose, - onIssueSelect, - onIssuesOpen, - onIssueUnselect, - openIssues: true, - }); - const instance = wrapper.instance(); - - instance.handleIssuesIndicatorClick(); - expect(onIssuesClose).toHaveBeenCalledWith(line); - expect(onIssueUnselect).toHaveBeenCalled(); - - wrapper.setProps({ openIssues: false }); - instance.handleIssuesIndicatorClick(); - expect(onIssuesOpen).toHaveBeenCalledWith(line); - expect(onIssueSelect).toHaveBeenCalledWith(issue.key); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx deleted file mode 100644 index 53fa5ed13e3..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCode-test.tsx +++ /dev/null @@ -1,53 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { mockSourceLine } from '../../../../helpers/mocks/sources'; -import LineCode from '../LineCode'; - -it('render code', () => { - expect(shallowRender()).toMatchSnapshot(); - expect(shallowRender({ children:
    additional child
    })).toMatchSnapshot( - 'with additional child' - ); - expect( - shallowRender({ - secondaryIssueLocations: [ - { index: 1, from: 5, to: 6, line: 16, startLine: 16, text: 'secondary-location-msg' }, - ], - }) - ).toMatchSnapshot('with secondary location'); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx deleted file mode 100644 index e687e93c345..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineCoverage-test.tsx +++ /dev/null @@ -1,48 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { LineCoverage, LineCoverageProps } from '../LineCoverage'; - -jest.mock('react', () => { - return { - ...jest.requireActual('react'), - useRef: jest.fn(), - useEffect: jest.fn(), - }; -}); - -it('should correctly trigger a scroll', () => { - const scroll = jest.fn(); - const element = { current: { scrollIntoView: scroll } }; - (React.useEffect as jest.Mock).mockImplementation((f) => f()); - (React.useRef as jest.Mock).mockImplementation(() => element); - - shallowRender({ scrollToUncoveredLine: true }); - expect(scroll).toHaveBeenCalled(); - - scroll.mockReset(); - shallowRender({ scrollToUncoveredLine: false }); - expect(scroll).not.toHaveBeenCalled(); -}); - -function shallowRender(props: Partial = {}) { - return shallow(); -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx deleted file mode 100644 index bb4ed9bf390..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineIssuesIndicator-test.tsx +++ /dev/null @@ -1,63 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { mockIssue } from '../../../../helpers/testMocks'; -import { click } from '../../../../helpers/testUtils'; -import { ButtonPlain } from '../../../controls/buttons'; -import { LineIssuesIndicator, LineIssuesIndicatorProps } from '../LineIssuesIndicator'; - -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot('default'); - expect( - shallowRender({ - issues: [ - mockIssue(false, { key: 'foo', type: 'VULNERABILITY' }), - mockIssue(false, { key: 'bar', type: 'VULNERABILITY' }), - ], - }) - ).toMatchSnapshot('multiple issues, same type'); - expect( - shallowRender({ issues: [mockIssue(false, { key: 'foo', type: 'VULNERABILITY' })] }) - ).toMatchSnapshot('single issue'); - expect(shallowRender({ issues: [] })).toMatchSnapshot('no issues'); -}); - -it('should correctly handle click', () => { - const onClick = jest.fn(); - const wrapper = shallowRender({ onClick }); - - click(wrapper.find(ButtonPlain)); - expect(onClick).toHaveBeenCalled(); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx deleted file mode 100644 index 388e130a15a..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/LineNumber-test.tsx +++ /dev/null @@ -1,34 +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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { LineNumber, LineNumberProps } from '../LineNumber'; - -it('should render correctly', () => { - expect(shallowRender({ displayOptions: false, line: { line: 12 } })).toMatchSnapshot( - 'no options' - ); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap deleted file mode 100644 index 9ba0983bbf1..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/Line-test.tsx.snap +++ /dev/null @@ -1,64 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly for last, new, and highlighted lines 1`] = ` - - import java.util.ArrayList;", - "coverageStatus": "covered", - "coveredConditions": 2, - "duplicated": false, - "isNew": true, - "line": 16, - "scmAuthor": "simon.brandhof@sonarsource.com", - "scmDate": "2018-12-11T10:48:39+0100", - "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", - } - } - /> - import java.util.ArrayList;", - "coverageStatus": "covered", - "coveredConditions": 2, - "duplicated": false, - "isNew": true, - "line": 16, - "scmAuthor": "simon.brandhof@sonarsource.com", - "scmDate": "2018-12-11T10:48:39+0100", - "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", - } - } - /> - - import java.util.ArrayList;", - "coverageStatus": "covered", - "coveredConditions": 2, - "duplicated": false, - "isNew": true, - "line": 16, - "scmAuthor": "simon.brandhof@sonarsource.com", - "scmDate": "2018-12-11T10:48:39+0100", - "scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0", - } - } - onLocationSelect={[MockFunction]} - onSymbolClick={[MockFunction]} - secondaryIssueLocations={[]} - /> - -`; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap deleted file mode 100644 index f7d93519ce9..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineCode-test.tsx.snap +++ /dev/null @@ -1,150 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`render code 1`] = ` - -
    -
    -      
    -        impor
    -      
    -      
    -        t
    -      
    -      
    -         java.util.
    -      
    -      
    -        ArrayList
    -      
    -      
    -        ;
    -      
    -    
    -
    - -`; - -exports[`render code: with additional child 1`] = ` - -
    -
    -      
    -        impor
    -      
    -      
    -        t
    -      
    -      
    -         java.util.
    -      
    -      
    -        ArrayList
    -      
    -      
    -        ;
    -      
    -    
    -
    -
    - additional child -
    - -`; - -exports[`render code: with secondary location 1`] = ` - -
    -
    -      
    -        impor
    -      
    -      
    -        }
    -        placement="top"
    -      >
    -        
    -          
    -            
    -          
    -        
    -      
    -      
    -        t
    -      
    -      
    -         java.util.
    -      
    -      
    -        ArrayList
    -      
    -      
    -        ;
    -      
    -    
    -
    - -`; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap deleted file mode 100644 index 5707fd2491d..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineIssuesIndicator-test.tsx.snap +++ /dev/null @@ -1,78 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly: default 1`] = ` - - - - - - 2 - - - - -`; - -exports[`should render correctly: multiple issues, same type 1`] = ` - - - - - - 2 - - - - -`; - -exports[`should render correctly: no issues 1`] = ` - -`; - -exports[`should render correctly: single issue 1`] = ` - - - - - - - -`; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap deleted file mode 100644 index 4d1071f9d80..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/__tests__/__snapshots__/LineNumber-test.tsx.snap +++ /dev/null @@ -1,10 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly: no options 1`] = ` - - 12 - -`; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts index c6985a0a98e..fc40964ec8d 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/highlight-test.ts @@ -23,33 +23,33 @@ describe('highlightSymbol', () => { it('should not highlight symbols with similar beginning', () => { // test all positions of sym-X in the string: beginning, middle and ending const tokens = [ - { className: 'sym-18 b', markers: [], text: 'foo' }, - { className: 'a sym-18', markers: [], text: 'foo' }, - { className: 'a sym-18 b', markers: [], text: 'foo' }, - { className: 'sym-1 d', markers: [], text: 'bar' }, - { className: 'c sym-1', markers: [], text: 'bar' }, - { className: 'c sym-1 d', markers: [], text: 'bar' }, + { className: 'sym-18 b', markers: [], text: 'foo', modifiers: {} }, + { className: 'a sym-18', markers: [], text: 'foo', modifiers: {} }, + { className: 'a sym-18 b', markers: [], text: 'foo', modifiers: {} }, + { className: 'sym-1 d', markers: [], text: 'bar', modifiers: {} }, + { className: 'c sym-1', markers: [], text: 'bar', modifiers: {} }, + { className: 'c sym-1 d', markers: [], text: 'bar', modifiers: {} }, ]; expect(highlightSymbol(tokens, 'sym-1')).toEqual([ - { className: 'sym-18 b', markers: [], text: 'foo' }, - { className: 'a sym-18', markers: [], text: 'foo' }, - { className: 'a sym-18 b', markers: [], text: 'foo' }, - { className: 'sym-1 d highlighted', markers: [], text: 'bar' }, - { className: 'c sym-1 highlighted', markers: [], text: 'bar' }, - { className: 'c sym-1 d highlighted', markers: [], text: 'bar' }, + { className: 'sym-18 b', markers: [], text: 'foo', modifiers: {} }, + { className: 'a sym-18', markers: [], text: 'foo', modifiers: {} }, + { className: 'a sym-18 b', markers: [], text: 'foo', modifiers: {} }, + { className: 'sym-1 d highlighted', markers: [], text: 'bar', modifiers: {} }, + { className: 'c sym-1 highlighted', markers: [], text: 'bar', modifiers: {} }, + { className: 'c sym-1 d highlighted', markers: [], text: 'bar', modifiers: {} }, ]); }); it('should highlight symbols marked twice', () => { const tokens = [ - { className: 'sym sym-1 sym sym-2', markers: [], text: 'foo' }, - { className: 'sym sym-1', markers: [], text: 'bar' }, - { className: 'sym sym-2', markers: [], text: 'qux' }, + { className: 'sym sym-1 sym sym-2', markers: [], text: 'foo', modifiers: {} }, + { className: 'sym sym-1', markers: [], text: 'bar', modifiers: {} }, + { className: 'sym sym-2', markers: [], text: 'qux', modifiers: {} }, ]; expect(highlightSymbol(tokens, 'sym-1')).toEqual([ - { className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo' }, - { className: 'sym sym-1 highlighted', markers: [], text: 'bar' }, - { className: 'sym sym-2', markers: [], text: 'qux' }, + { className: 'sym sym-1 sym sym-2 highlighted', markers: [], text: 'foo', modifiers: {} }, + { className: 'sym sym-1 highlighted', markers: [], text: 'bar', modifiers: {} }, + { className: 'sym sym-2', markers: [], text: 'qux', modifiers: {} }, ]); }); }); diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts index f93bdaf41e6..0dcfdc73e21 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/highlight.ts @@ -20,9 +20,17 @@ import { uniq } from 'lodash'; import { LinearIssueLocation } from '../../../types/types'; +export interface TokenModifiers { + isHighlighted?: boolean; + isLocation?: boolean; + isSelected?: boolean; + isUnderlined?: boolean; +} + export interface Token { className: string; markers: number[]; + modifiers: TokenModifiers; text: string; } @@ -39,7 +47,7 @@ export function splitByTokens(code: NodeListOf, rootClassName = ''): } if (node.nodeType === 3 && node.nodeValue) { // TEXT NODE - tokens.push({ className: rootClassName, markers: [], text: node.nodeValue }); + tokens.push({ className: rootClassName, markers: [], text: node.nodeValue, modifiers: {} }); } }); return tokens; @@ -83,6 +91,7 @@ function part(str: string, from: number, to: number, acc: number): string { export function highlightIssueLocations( tokens: Token[], issueLocations: LinearIssueLocation[], + modifier: keyof TokenModifiers, rootClassName: string = ISSUE_LOCATION_CLASS ): Token[] { issueLocations.forEach((location) => { @@ -104,6 +113,10 @@ export function highlightIssueLocations( : token.className; nextTokens.push({ className: newClassName, + modifiers: { + ...token.modifiers, + [modifier]: true, + }, markers: !markerAdded && location.index != null ? uniq([...token.markers, location.index]) @@ -121,3 +134,53 @@ export function highlightIssueLocations( }); return tokens; } + +export const getHighlightedTokens = (params: { + code: string | undefined; + highlightedLocationMessage: { index: number; text: string | undefined } | undefined; + highlightedSymbols: string[] | undefined; + issueLocations: LinearIssueLocation[]; + secondaryIssueLocations: LinearIssueLocation[]; +}) => { + const { + code, + highlightedLocationMessage, + highlightedSymbols, + issueLocations, + secondaryIssueLocations, + } = params; + + const container = document.createElement('div'); + container.innerHTML = code ?? ''; + let tokens = splitByTokens(container.childNodes); + + if (highlightedSymbols) { + highlightedSymbols.forEach((symbol) => { + tokens = highlightSymbol(tokens, symbol); + }); + } + + if (issueLocations.length > 0) { + tokens = highlightIssueLocations(tokens, issueLocations, 'isUnderlined', 'isUnderlined'); + } + + if (secondaryIssueLocations) { + tokens = highlightIssueLocations( + tokens, + secondaryIssueLocations, + 'isLocation', + 'issue-location' + ); + + if (highlightedLocationMessage) { + const location = secondaryIssueLocations.find( + (location) => location.index === highlightedLocationMessage.index + ); + if (location) { + tokens = highlightIssueLocations(tokens, [location], 'isSelected', 'selected'); + } + } + } + + return tokens; +}; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts index bafd011e8ff..b2d66dbe1a6 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts +++ b/server/sonar-web/src/main/js/components/SourceViewer/helpers/lines.ts @@ -42,3 +42,16 @@ export function optimizeLocationMessage( ? highlightedLocationMessage : undefined; } + +/** + * Parse lineCode HTML and return text nodes content only + */ +export const getLineCodeAsPlainText = (lineCode?: string) => { + if (!lineCode) { + return ''; + } + const domParser = new DOMParser(); + const domDoc = domParser.parseFromString(lineCode, 'text/html'); + const bodyElements = domDoc.getElementsByTagName('body'); + return bodyElements.length && bodyElements[0].textContent ? bodyElements[0].textContent : ''; +}; diff --git a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx index eeff7ebe1a7..eb1436e837b 100644 --- a/server/sonar-web/src/main/js/components/controls/Tooltip.tsx +++ b/server/sonar-web/src/main/js/components/controls/Tooltip.tsx @@ -44,6 +44,7 @@ export interface TooltipProps { // default behavior of tabbing (other changes should be done outside of this component to make it work) // See example DocumentationTooltip isInteractive?: boolean; + classNameInner?: string; } interface Measurements { @@ -394,11 +395,12 @@ export class TooltipInner extends React.Component { renderOverlay() { const isVisible = this.isVisible(); - const { classNameSpace = 'tooltip', isInteractive, overlay } = this.props; - + const { classNameSpace = 'tooltip', isInteractive, overlay, classNameInner } = this.props; return (