diff options
author | David Cho-Lerat <david.cho-lerat@sonarsource.com> | 2023-06-22 17:04:33 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-06-23 20:03:17 +0000 |
commit | bb2fa45b4e8d9e1f1e3d0842e3663ee41a109624 (patch) | |
tree | 7cd44852ccb485cbb70040635d4696fc86ed163c /server/sonar-web | |
parent | 92a86d4caf583a8778d2355d4bc11080f9edb093 (diff) | |
download | sonarqube-bb2fa45b4e8d9e1f1e3d0842e3663ee41a109624.tar.gz sonarqube-bb2fa45b4e8d9e1f1e3d0842e3663ee41a109624.zip |
SONAR-19638 Add syntax highlighting to code snippets in rule details
Diffstat (limited to 'server/sonar-web')
23 files changed, 491 insertions, 1664 deletions
diff --git a/server/sonar-web/design-system/package.json b/server/sonar-web/design-system/package.json index 2ca10017669..3d8cf39ac87 100644 --- a/server/sonar-web/design-system/package.json +++ b/server/sonar-web/design-system/package.json @@ -35,6 +35,9 @@ "eslint-plugin-local-rules": "1.3.2", "eslint-plugin-typescript-sort-keys": "2.3.0", "highlight.js": "11.7.0", + "highlightjs-apex": "1.2.0", + "highlightjs-cobol": "0.3.3", + "highlightjs-sap-abap": "0.2.0", "history": "5.3.0", "jest": "29.5.0", "postcss": "8.4.21", diff --git a/server/sonar-web/design-system/src/components/__tests__/Highlighter-test.tsx b/server/sonar-web/design-system/src/@types/highlightjs-apex.d.ts index c02ca7f6e51..efcfd753359 100644 --- a/server/sonar-web/design-system/src/components/__tests__/Highlighter-test.tsx +++ b/server/sonar-web/design-system/src/@types/highlightjs-apex.d.ts @@ -17,34 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +declare module 'highlightjs-apex' { + import { LanguageFn } from 'highlight.js'; -import { renderWithContext } from '../../helpers/testUtils'; -import { FCProps } from '../../types/misc'; -import { Highlighter } from '../Highlighter'; - -it('renders correctly', () => { - expect(setupWithProps().container).toMatchSnapshot(); -}); - -it('should handle multiple lines of code', () => { - expect( - setupWithProps({ - code: `foo: bar - pleh: help - stuff: - foo: bar - bar: foo`, - language: 'yaml', - }).container - ).toMatchSnapshot(); -}); - -it('should display edit functions', () => { - expect( - setupWithProps({ code: 'One line command', toggleEdit: jest.fn() }).container - ).toMatchSnapshot(); -}); - -function setupWithProps(props: Partial<FCProps<typeof Highlighter>> = {}) { - return renderWithContext(<Highlighter code="foo\nbar" {...props} />); + const defineLanguage: LanguageFn; + // eslint-disable-next-line import/no-default-export + export default defineLanguage; } diff --git a/server/sonar-web/design-system/src/@types/highlightjs-sap-abap.d.ts b/server/sonar-web/design-system/src/@types/highlightjs-sap-abap.d.ts new file mode 100644 index 00000000000..d133b6e7f71 --- /dev/null +++ b/server/sonar-web/design-system/src/@types/highlightjs-sap-abap.d.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ +declare module 'highlightjs-sap-abap' { + import { LanguageFn } from 'highlight.js'; + + const defineLanguage: LanguageFn; + // eslint-disable-next-line import/no-default-export + export default defineLanguage; +} diff --git a/server/sonar-web/design-system/src/components/CodeSnippet.tsx b/server/sonar-web/design-system/src/components/CodeSnippet.tsx deleted file mode 100644 index e1fed4f8808..00000000000 --- a/server/sonar-web/design-system/src/components/CodeSnippet.tsx +++ /dev/null @@ -1,120 +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 styled from '@emotion/styled'; -import classNames from 'classnames'; -import tw from 'twin.macro'; -import { themeBorder, themeColor } from '../helpers/theme'; -import { isDefined } from '../helpers/types'; -import { Highlighter, RegisteredLanguages } from './Highlighter'; -import { ClipboardButton } from './clipboard'; - -interface Props { - className?: string; - highlight?: boolean; - isOneLine?: boolean; - join?: string; - language?: RegisteredLanguages; - noCopy?: boolean; - render?: string; - snippet: string | Array<string | undefined>; - toggleEdit?: VoidFunction; - wrap?: boolean; -} - -// keep this "useless" concatenation for the readability reason -// eslint-disable-next-line no-useless-concat -const s = ' \\' + '\n '; - -export function CodeSnippet(props: Props) { - const { - className, - isOneLine, - highlight, - join = s, - language, - noCopy, - render, - snippet, - toggleEdit, - wrap, - } = props; - const snippetArray = Array.isArray(snippet) ? snippet.filter(isDefined) : [snippet]; - const finalSnippet = isOneLine ? snippetArray.join(' ') : snippetArray.join(join); - - const isSimpleOneLine = isOneLine && noCopy; - - const copyButton = isOneLine ? ( - <StyledSingleLineClipboardButton copyValue={finalSnippet} /> - ) : ( - <StyledClipboardButton copyValue={finalSnippet} /> - ); - - return ( - <Wrapper - className={classNames( - { - 'code-snippet-highlighted-oneline': isOneLine, - 'code-snippet-simple-oneline': isSimpleOneLine, - }, - className, - 'fs-mask' - )} - > - {!noCopy && copyButton} - <Highlighter - code={render ?? finalSnippet} - highlight={highlight} - isSimpleOneLine={isSimpleOneLine} - language={language} - toggleEdit={isOneLine ? toggleEdit : undefined} - wrap={wrap} - /> - </Wrapper> - ); -} - -const Wrapper = styled.div` - background-color: ${themeColor('codeSnippetBackground')}; - border: ${themeBorder('default', 'codeSnippetBorder')}; - - ${tw`sw-rounded-2`} - ${tw`sw-relative`} - ${tw`sw-my-2`} - - &.code-snippet-simple-oneline { - ${tw`sw-my-0`} - ${tw`sw-rounded-1`} - } -`; - -const StyledClipboardButton = styled(ClipboardButton)` - ${tw`sw-select-none`} - ${tw`sw-body-sm`} - ${tw`sw-top-6 sw-right-6`} - ${tw`sw-absolute`} - - .code-snippet-highlighted-oneline & { - ${tw`sw-bottom-2`} - } -`; - -const StyledSingleLineClipboardButton = styled(StyledClipboardButton)` - ${tw`sw-top-6 sw-bottom-6`} -`; diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx new file mode 100644 index 00000000000..487b2223be6 --- /dev/null +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -0,0 +1,153 @@ +/* + * 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 hljs from 'highlight.js'; +import apex from 'highlightjs-apex'; +import cobol from 'highlightjs-cobol'; +import abap from 'highlightjs-sap-abap'; +import tw from 'twin.macro'; +import { themeColor, themeContrast } from '../helpers/theme'; + +hljs.registerLanguage('abap', abap); +hljs.registerLanguage('apex', apex); +hljs.registerLanguage('cobol', cobol); + +hljs.registerAliases('azureresourcemanager', { languageName: 'json' }); +hljs.registerAliases('flex', { languageName: 'actionscript' }); +hljs.registerAliases('objc', { languageName: 'objectivec' }); +hljs.registerAliases('plsql', { languageName: 'pgsql' }); +hljs.registerAliases('secrets', { languageName: 'markdown' }); +hljs.registerAliases('web', { languageName: 'xml' }); +hljs.registerAliases(['cloudformation', 'kubernetes'], { languageName: 'yaml' }); + +interface Props { + className?: string; + htmlAsString: string; + language?: string; +} + +const CODE_REGEXP = '<(code|pre)\\b([^>]*?)>(.+?)<\\/\\1>'; +const GLOBAL_REGEXP = new RegExp(CODE_REGEXP, 'gs'); +const SINGLE_REGEXP = new RegExp(CODE_REGEXP, 's'); + +const htmlDecode = (escapedCode: string) => { + const doc = new DOMParser().parseFromString(escapedCode, 'text/html'); + + return doc.documentElement.textContent ?? ''; +}; + +export function CodeSyntaxHighlighter({ className, htmlAsString, language }: Props) { + let highlightedHtmlAsString = htmlAsString; + + htmlAsString.match(GLOBAL_REGEXP)?.forEach((codeBlock) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [, tag, attributes, code] = SINGLE_REGEXP.exec(codeBlock)!; + + const unescapedCode = htmlDecode(code); + + let highlightedCode; + + try { + highlightedCode = hljs.highlight(unescapedCode, { + ignoreIllegals: true, + language: language ?? 'plaintext', + }); + } catch { + highlightedCode = hljs.highlight(unescapedCode, { + ignoreIllegals: true, + language: 'plaintext', + }); + } + + highlightedHtmlAsString = highlightedHtmlAsString.replace( + codeBlock, + `<${tag}${attributes}>${highlightedCode.value}</${tag}>` + ); + }); + + return ( + <StyledSpan + className={`hljs ${className ?? ''}`} + // Safe: value is escaped by highlight.js + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: highlightedHtmlAsString }} + /> + ); +} + +const StyledSpan = styled.span` + code { + ${tw`sw-code`}; + + background: ${themeColor('codeSnippetBackground')}; + color: ${themeColor('codeSnippetBody')}; + + &.hljs { + padding: unset; + } + } + + .hljs-meta, + .hljs-variable { + color: ${themeColor('codeSnippetBody')}; + } + + .hljs-doctag, + .hljs-title, + .hljs-title.class_, + .hljs-title.function_ { + color: ${themeColor('codeSnippetAnnotations')}; + } + + .hljs-comment { + ${tw`sw-code-comment`} + + color: ${themeColor('codeSnippetComments')}; + } + + .hljs-keyword, + .hljs-tag, + .hljs-type { + color: ${themeColor('codeSnippetKeyword')}; + } + + .hljs-literal, + .hljs-number { + color: ${themeColor('codeSnippetConstants')}; + } + + .hljs-string { + color: ${themeColor('codeSnippetString')}; + } + + .hljs-meta .hljs-keyword { + color: ${themeColor('codeSnippetPreprocessingDirective')}; + } + + mark { + ${tw`sw-font-regular`} + ${tw`sw-p-1`} + ${tw`sw-rounded-1`} + + background-color: ${themeColor('codeSnippetHighlight')}; + color: ${themeContrast('codeSnippetHighlight')}; + } +`; diff --git a/server/sonar-web/design-system/src/components/Highlighter.tsx b/server/sonar-web/design-system/src/components/Highlighter.tsx deleted file mode 100644 index 73690313334..00000000000 --- a/server/sonar-web/design-system/src/components/Highlighter.tsx +++ /dev/null @@ -1,182 +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 styled from '@emotion/styled'; -import classNames from 'classnames'; -import hljs from 'highlight.js/lib/core'; -import bash from 'highlight.js/lib/languages/bash'; -import gradle from 'highlight.js/lib/languages/gradle'; -import plaintext from 'highlight.js/lib/languages/plaintext'; -import powershell from 'highlight.js/lib/languages/powershell'; -import properties from 'highlight.js/lib/languages/properties'; -import shell from 'highlight.js/lib/languages/shell'; -import xml from 'highlight.js/lib/languages/xml'; -import yaml from 'highlight.js/lib/languages/yaml'; -import { useMemo } from 'react'; -import tw from 'twin.macro'; -import { translate } from '../helpers/l10n'; -import { themeColor, themeContrast } from '../helpers/theme'; -import { InteractiveIcon } from './InteractiveIcon'; -import { PencilIcon } from './icons'; - -hljs.registerLanguage('yaml', yaml); -hljs.registerLanguage('gradle', gradle); -hljs.registerLanguage('properties', properties); -hljs.registerLanguage('xml', xml); -hljs.registerLanguage('bash', bash); -hljs.registerLanguage('powershell', powershell); -hljs.registerLanguage('shell', shell); -hljs.registerLanguage('plaintext', plaintext); - -hljs.addPlugin({ - 'after:highlight': (data) => { - data.value = data.value - .replace(/<mark>/g, '<mark>') - .replace(/<\/mark>/g, '</mark>'); - }, -}); - -export type RegisteredLanguages = - | 'bash' - | 'gradle' - | 'plaintext' - | 'powershell' - | 'properties' - | 'shell' - | 'xml' - | 'yaml'; - -interface Props { - className?: string; - code: string; - highlight?: boolean; - isSimpleOneLine?: boolean; - language?: RegisteredLanguages; - toggleEdit?: VoidFunction; - wrap?: boolean; -} - -export function Highlighter({ - className, - code, - highlight = true, - isSimpleOneLine = false, - language = 'yaml', - toggleEdit, - wrap, -}: Props) { - const highlighted = useMemo( - () => hljs.highlight(code, { language: highlight ? language : 'plaintext' }), - [code, highlight, language] - ); - - return ( - <StyledPre - className={classNames({ 'code-wrap': wrap, 'simple-one-line': isSimpleOneLine }, className)} - > - <code - className={classNames('hljs', { 'sw-inline': toggleEdit })} - // Safe: value is escaped by highlight.js - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: highlighted.value }} - /> - {toggleEdit && ( - <InteractiveIcon - Icon={PencilIcon} - aria-label={translate('edit')} - className="sw-ml-2" - onClick={toggleEdit} - /> - )} - </StyledPre> - ); -} - -const StyledPre = styled.pre` - ${tw`sw-flex sw-items-center`} - ${tw`sw-overflow-x-auto`} - ${tw`sw-p-6`} - - code { - color: ${themeColor('codeSnippetBody')}; - background: ${themeColor('codeSnippetBackground')}; - ${tw`sw-code`}; - - &.hljs { - padding: unset; - } - } - - .hljs-variable, - .hljs-meta { - color: ${themeColor('codeSnippetBody')}; - } - - .hljs-doctag, - .hljs-title, - .hljs-title.class_, - .hljs-title.function_ { - color: ${themeColor('codeSnippetAnnotations')}; - } - - .hljs-comment { - color: ${themeColor('codeSnippetComments')}; - - ${tw`sw-code-comment`} - } - - .hljs-tag, - .hljs-type, - .hljs-keyword { - color: ${themeColor('codeSnippetKeyword')}; - - ${tw`sw-code-highlight`} - } - - .hljs-literal, - .hljs-number { - color: ${themeColor('codeSnippetConstants')}; - } - - .hljs-string { - color: ${themeColor('codeSnippetString')}; - } - - .hljs-meta .hljs-keyword { - color: ${themeColor('codeSnippetPreprocessingDirective')}; - } - - &.code-wrap { - ${tw`sw-whitespace-pre-wrap`} - ${tw`sw-break-all`} - } - - mark { - color: ${themeContrast('codeSnippetHighlight')}; - background-color: ${themeColor('codeSnippetHighlight')}; - ${tw`sw-font-regular`} - ${tw`sw-rounded-1`} - ${tw`sw-p-1`} - } - - &.simple-one-line { - ${tw`sw-min-h-[1.25rem]`} - ${tw`sw-py-0 sw-px-1`} - } -`; diff --git a/server/sonar-web/design-system/src/components/__tests__/CodeSnippet-test.tsx b/server/sonar-web/design-system/src/components/__tests__/CodeSnippet-test.tsx deleted file mode 100644 index 87bca8a7bdc..00000000000 --- a/server/sonar-web/design-system/src/components/__tests__/CodeSnippet-test.tsx +++ /dev/null @@ -1,46 +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 { screen } from '@testing-library/react'; -import { HelmetProvider } from 'react-helmet-async'; -import { renderWithContext } from '../../helpers/testUtils'; -import { FCProps } from '../../types/misc'; -import { CodeSnippet } from '../CodeSnippet'; - -it('should show full size when multiline with no editting', () => { - const { container } = setupWithProps(); - const copyButton = screen.getByRole('button', { name: 'Copy' }); - expect(copyButton).toHaveStyle('top: 1.5rem'); - expect(container).toMatchSnapshot(); -}); - -it('should show reduced size when single line with no editting', () => { - const { container } = setupWithProps({ isOneLine: true, snippet: 'foobar' }); - const copyButton = screen.getByRole('button', { name: 'Copy' }); - expect(copyButton).toHaveStyle('top: 1.5rem'); - expect(container).toMatchSnapshot(); -}); - -function setupWithProps(props: Partial<FCProps<typeof CodeSnippet>> = {}) { - return renderWithContext( - <HelmetProvider> - <CodeSnippet snippet={'foo\nbar'} {...props} /> - </HelmetProvider> - ); -} diff --git a/server/sonar-web/design-system/src/components/__tests__/CodeSyntaxHighlighter-test.tsx b/server/sonar-web/design-system/src/components/__tests__/CodeSyntaxHighlighter-test.tsx new file mode 100644 index 00000000000..a55e079b885 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/CodeSyntaxHighlighter-test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { CodeSyntaxHighlighter } from '../CodeSyntaxHighlighter'; + +it('renders correctly with no code', () => { + const { container } = render( + <CodeSyntaxHighlighter + htmlAsString={` + <p>Hello there!</p> + + <p>There's no code here.</p> + `} + /> + ); + + // eslint-disable-next-line testing-library/no-node-access + expect(container.getElementsByClassName('hljs-string').length).toBe(0); +}); + +it('renders correctly with code', () => { + const { container } = render( + <CodeSyntaxHighlighter + htmlAsString={` + <p>Hello there!</p> + + <p>There's some <code>"code"</code> here.</p> + `} + language="typescript" + /> + ); + + // eslint-disable-next-line testing-library/no-node-access + expect(container.getElementsByClassName('hljs-string').length).toBe(1); +}); 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 deleted file mode 100644 index ec00317fed2..00000000000 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/CodeSnippet-test.tsx.snap +++ /dev/null @@ -1,473 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should show full size when multiline with no editting 1`] = ` -.emotion-0 { - background-color: rgb(252,252,253); - border: 1px solid rgb(225,230,243); - border-radius: 0.5rem; - position: relative; - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.emotion-0.code-snippet-simple-oneline { - margin-top: 0; - margin-bottom: 0; - border-radius: 0.25rem; -} - -.emotion-4 { - box-sizing: border-box; - -webkit-text-decoration: none; - text-decoration: none; - outline: none; - border: var(--border); - color: var(--color); - background-color: var(--background); - -webkit-transition: background-color 0.2s ease,outline 0.2s ease; - transition: background-color 0.2s ease,outline 0.2s ease; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - height: 2.25rem; - font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 600; - padding-left: 1rem; - padding-right: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - border-radius: 0.5rem; - cursor: pointer; - --background: rgb(255,255,255); - --backgroundHover: rgb(239,242,249); - --color: rgb(62,67,87); - --focus: rgba(197,205,223,0.2); - --border: 1px solid rgb(197,205,223); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 400; - right: 1.5rem; - top: 1.5rem; - position: absolute; -} - -.emotion-4:hover { - color: var(--color); - background-color: var(--backgroundHover); -} - -.emotion-4:focus, -.emotion-4:active { - color: var(--color); - outline: 4px solid var(--focus); -} - -.emotion-4:disabled, -.emotion-4:disabled:hover { - color: rgb(166,173,194); - background-color: rgb(239,242,249); - border: 1px solid rgb(197,205,223); - cursor: not-allowed; -} - -.emotion-4>svg { - margin-right: 0.25rem; -} - -.emotion-4 [disabled] { - pointer-events: none; -} - -.code-snippet-highlighted-oneline .emotion-4 { - bottom: 0.5rem; -} - -.emotion-6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - overflow-x: auto; - padding: 1.5rem; -} - -.emotion-6 code { - color: rgb(51,53,60); - 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.125rem; - font-weight: 400; -} - -.emotion-6 code.hljs { - padding: unset; -} - -.emotion-6 .hljs-variable, -.emotion-6 .hljs-meta { - color: rgb(51,53,60); -} - -.emotion-6 .hljs-doctag, -.emotion-6 .hljs-title, -.emotion-6 .hljs-title.class_, -.emotion-6 .hljs-title.function_ { - color: rgb(34,84,192); -} - -.emotion-6 .hljs-comment { - 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.125rem; - font-style: italic; -} - -.emotion-6 .hljs-tag, -.emotion-6 .hljs-type, -.emotion-6 .hljs-keyword { - 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.125rem; - font-weight: 700; -} - -.emotion-6 .hljs-literal, -.emotion-6 .hljs-number { - color: rgb(126,83,5); -} - -.emotion-6 .hljs-string { - color: rgb(32,105,31); -} - -.emotion-6 .hljs-meta .hljs-keyword { - color: rgb(47,103,48); -} - -.emotion-6.code-wrap { - white-space: pre-wrap; - word-break: break-all; -} - -.emotion-6 mark { - color: rgb(217,45,32); - background-color: rgb(197,205,223); - font-weight: 400; - border-radius: 0.25rem; - padding: 0.25rem; -} - -.emotion-6.simple-one-line { - min-height: 1.25rem; - padding-left: 0.25rem; - padding-right: 0.25rem; - padding-top: 0; - padding-bottom: 0; -} - -<div> - <div - class="fs-mask emotion-0 emotion-1" - > - <button - aria-describedby="tooltip-1" - class="sw-select-none emotion-2 emotion-3 emotion-4 emotion-5" - data-clipboard-text="foo -bar" - type="button" - > - <svg - aria-hidden="true" - class="octicon octicon-copy" - fill="currentColor" - focusable="false" - height="16" - role="img" - style="display: inline-block; user-select: none; vertical-align: middle; overflow: visible;" - viewBox="0 0 16 16" - width="16" - > - <path - d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" - /> - <path - d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" - /> - </svg> - Copy - </button> - <pre - class=" emotion-6 emotion-7" - > - <code - class="hljs" - > - <span - class="hljs-string" - > - foo - </span> - - - <span - class="hljs-string" - > - bar - </span> - </code> - </pre> - </div> -</div> -`; - -exports[`should show reduced size when single line with no editting 1`] = ` -.emotion-0 { - background-color: rgb(252,252,253); - border: 1px solid rgb(225,230,243); - border-radius: 0.5rem; - position: relative; - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.emotion-0.code-snippet-simple-oneline { - margin-top: 0; - margin-bottom: 0; - border-radius: 0.25rem; -} - -.emotion-4 { - box-sizing: border-box; - -webkit-text-decoration: none; - text-decoration: none; - outline: none; - border: var(--border); - color: var(--color); - background-color: var(--background); - -webkit-transition: background-color 0.2s ease,outline 0.2s ease; - transition: background-color 0.2s ease,outline 0.2s ease; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - height: 2.25rem; - font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 600; - padding-left: 1rem; - padding-right: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - border-radius: 0.5rem; - cursor: pointer; - --background: rgb(255,255,255); - --backgroundHover: rgb(239,242,249); - --color: rgb(62,67,87); - --focus: rgba(197,205,223,0.2); - --border: 1px solid rgb(197,205,223); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - font-family: Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 400; - right: 1.5rem; - top: 1.5rem; - position: absolute; - bottom: 1.5rem; - top: 1.5rem; -} - -.emotion-4:hover { - color: var(--color); - background-color: var(--backgroundHover); -} - -.emotion-4:focus, -.emotion-4:active { - color: var(--color); - outline: 4px solid var(--focus); -} - -.emotion-4:disabled, -.emotion-4:disabled:hover { - color: rgb(166,173,194); - background-color: rgb(239,242,249); - border: 1px solid rgb(197,205,223); - cursor: not-allowed; -} - -.emotion-4>svg { - margin-right: 0.25rem; -} - -.emotion-4 [disabled] { - pointer-events: none; -} - -.code-snippet-highlighted-oneline .emotion-4 { - bottom: 0.5rem; -} - -.emotion-6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - overflow-x: auto; - padding: 1.5rem; -} - -.emotion-6 code { - color: rgb(51,53,60); - 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.125rem; - font-weight: 400; -} - -.emotion-6 code.hljs { - padding: unset; -} - -.emotion-6 .hljs-variable, -.emotion-6 .hljs-meta { - color: rgb(51,53,60); -} - -.emotion-6 .hljs-doctag, -.emotion-6 .hljs-title, -.emotion-6 .hljs-title.class_, -.emotion-6 .hljs-title.function_ { - color: rgb(34,84,192); -} - -.emotion-6 .hljs-comment { - 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.125rem; - font-style: italic; -} - -.emotion-6 .hljs-tag, -.emotion-6 .hljs-type, -.emotion-6 .hljs-keyword { - 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.125rem; - font-weight: 700; -} - -.emotion-6 .hljs-literal, -.emotion-6 .hljs-number { - color: rgb(126,83,5); -} - -.emotion-6 .hljs-string { - color: rgb(32,105,31); -} - -.emotion-6 .hljs-meta .hljs-keyword { - color: rgb(47,103,48); -} - -.emotion-6.code-wrap { - white-space: pre-wrap; - word-break: break-all; -} - -.emotion-6 mark { - color: rgb(217,45,32); - background-color: rgb(197,205,223); - font-weight: 400; - border-radius: 0.25rem; - padding: 0.25rem; -} - -.emotion-6.simple-one-line { - min-height: 1.25rem; - padding-left: 0.25rem; - padding-right: 0.25rem; - padding-top: 0; - padding-bottom: 0; -} - -<div> - <div - class="code-snippet-highlighted-oneline fs-mask emotion-0 emotion-1" - > - <button - aria-describedby="tooltip-2" - class="sw-select-none emotion-2 emotion-3 emotion-4 emotion-5" - data-clipboard-text="foobar" - type="button" - > - <svg - aria-hidden="true" - class="octicon octicon-copy" - fill="currentColor" - focusable="false" - height="16" - role="img" - style="display: inline-block; user-select: none; vertical-align: middle; overflow: visible;" - viewBox="0 0 16 16" - width="16" - > - <path - d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" - /> - <path - d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" - /> - </svg> - Copy - </button> - <pre - class=" emotion-6 emotion-7" - > - <code - class="hljs" - > - <span - class="hljs-string" - > - foobar - </span> - </code> - </pre> - </div> -</div> -`; 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 deleted file mode 100644 index d7fed058225..00000000000 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/Highlighter-test.tsx.snap +++ /dev/null @@ -1,463 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` -.emotion-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - overflow-x: auto; - padding: 1.5rem; -} - -.emotion-0 code { - color: rgb(51,53,60); - 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.125rem; - font-weight: 400; -} - -.emotion-0 code.hljs { - padding: unset; -} - -.emotion-0 .hljs-variable, -.emotion-0 .hljs-meta { - color: rgb(51,53,60); -} - -.emotion-0 .hljs-doctag, -.emotion-0 .hljs-title, -.emotion-0 .hljs-title.class_, -.emotion-0 .hljs-title.function_ { - color: rgb(34,84,192); -} - -.emotion-0 .hljs-comment { - 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.125rem; - font-style: italic; -} - -.emotion-0 .hljs-tag, -.emotion-0 .hljs-type, -.emotion-0 .hljs-keyword { - 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.125rem; - font-weight: 700; -} - -.emotion-0 .hljs-literal, -.emotion-0 .hljs-number { - color: rgb(126,83,5); -} - -.emotion-0 .hljs-string { - color: rgb(32,105,31); -} - -.emotion-0 .hljs-meta .hljs-keyword { - color: rgb(47,103,48); -} - -.emotion-0.code-wrap { - white-space: pre-wrap; - word-break: break-all; -} - -.emotion-0 mark { - color: rgb(217,45,32); - background-color: rgb(197,205,223); - font-weight: 400; - border-radius: 0.25rem; - padding: 0.25rem; -} - -.emotion-0.simple-one-line { - min-height: 1.25rem; - padding-left: 0.25rem; - padding-right: 0.25rem; - padding-top: 0; - padding-bottom: 0; -} - -<div> - <pre - class=" emotion-0 emotion-1" - > - <code - class="hljs" - > - <span - class="hljs-string" - > - foo\\nbar - </span> - </code> - </pre> -</div> -`; - -exports[`should display edit functions 1`] = ` -.emotion-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - overflow-x: auto; - padding: 1.5rem; -} - -.emotion-0 code { - color: rgb(51,53,60); - 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.125rem; - font-weight: 400; -} - -.emotion-0 code.hljs { - padding: unset; -} - -.emotion-0 .hljs-variable, -.emotion-0 .hljs-meta { - color: rgb(51,53,60); -} - -.emotion-0 .hljs-doctag, -.emotion-0 .hljs-title, -.emotion-0 .hljs-title.class_, -.emotion-0 .hljs-title.function_ { - color: rgb(34,84,192); -} - -.emotion-0 .hljs-comment { - 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.125rem; - font-style: italic; -} - -.emotion-0 .hljs-tag, -.emotion-0 .hljs-type, -.emotion-0 .hljs-keyword { - 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.125rem; - font-weight: 700; -} - -.emotion-0 .hljs-literal, -.emotion-0 .hljs-number { - color: rgb(126,83,5); -} - -.emotion-0 .hljs-string { - color: rgb(32,105,31); -} - -.emotion-0 .hljs-meta .hljs-keyword { - color: rgb(47,103,48); -} - -.emotion-0.code-wrap { - white-space: pre-wrap; - word-break: break-all; -} - -.emotion-0 mark { - color: rgb(217,45,32); - background-color: rgb(197,205,223); - font-weight: 400; - border-radius: 0.25rem; - padding: 0.25rem; -} - -.emotion-0.simple-one-line { - min-height: 1.25rem; - padding-left: 0.25rem; - padding-right: 0.25rem; - padding-top: 0; - padding-bottom: 0; -} - -.emotion-3 { - box-sizing: border-box; - border: none; - outline: none; - -webkit-text-decoration: none; - text-decoration: none; - color: var(--color); - background-color: var(--background); - -webkit-transition: background-color 0.2s ease,outline 0.2s ease,color 0.2s ease; - transition: background-color 0.2s ease,outline 0.2s ease,color 0.2s ease; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - cursor: pointer; - height: 2.25rem; - border-radius: 0.5rem; - padding-left: 0.625rem; - padding-right: 0.625rem; - --background: transparent; - --backgroundHover: rgb(232,235,255); - --color: rgb(75,86,187); - --colorHover: rgb(43,51,104); - --focus: rgba(93,108,208,0.2); -} - -.emotion-3:hover, -.emotion-3:focus, -.emotion-3:active { - color: var(--colorHover); - background-color: var(--backgroundHover); -} - -.emotion-3:focus, -.emotion-3:active { - outline: 4px solid var(--focus); -} - -.emotion-3:disabled, -.emotion-3:disabled:hover { - color: rgb(166,173,194); - background-color: var(--background); - cursor: not-allowed; -} - -<div> - <pre - class=" emotion-0 emotion-1" - > - <code - class="hljs sw-inline" - > - <span - class="hljs-string" - > - One - </span> - - <span - class="hljs-string" - > - line - </span> - - <span - class="hljs-string" - > - command - </span> - </code> - <button - aria-label="edit" - class="sw-ml-2 emotion-2 emotion-3 emotion-4" - type="button" - > - <svg - aria-hidden="true" - class="" - fill="currentColor" - focusable="false" - height="16" - role="img" - style="display: inline-block; user-select: none; vertical-align: middle; overflow: visible;" - viewBox="0 0 16 16" - width="16" - > - <path - d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" - /> - </svg> - </button> - </pre> -</div> -`; - -exports[`should handle multiple lines of code 1`] = ` -.emotion-0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - overflow-x: auto; - padding: 1.5rem; -} - -.emotion-0 code { - color: rgb(51,53,60); - 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.125rem; - font-weight: 400; -} - -.emotion-0 code.hljs { - padding: unset; -} - -.emotion-0 .hljs-variable, -.emotion-0 .hljs-meta { - color: rgb(51,53,60); -} - -.emotion-0 .hljs-doctag, -.emotion-0 .hljs-title, -.emotion-0 .hljs-title.class_, -.emotion-0 .hljs-title.function_ { - color: rgb(34,84,192); -} - -.emotion-0 .hljs-comment { - 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.125rem; - font-style: italic; -} - -.emotion-0 .hljs-tag, -.emotion-0 .hljs-type, -.emotion-0 .hljs-keyword { - 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.125rem; - font-weight: 700; -} - -.emotion-0 .hljs-literal, -.emotion-0 .hljs-number { - color: rgb(126,83,5); -} - -.emotion-0 .hljs-string { - color: rgb(32,105,31); -} - -.emotion-0 .hljs-meta .hljs-keyword { - color: rgb(47,103,48); -} - -.emotion-0.code-wrap { - white-space: pre-wrap; - word-break: break-all; -} - -.emotion-0 mark { - color: rgb(217,45,32); - background-color: rgb(197,205,223); - font-weight: 400; - border-radius: 0.25rem; - padding: 0.25rem; -} - -.emotion-0.simple-one-line { - min-height: 1.25rem; - padding-left: 0.25rem; - padding-right: 0.25rem; - padding-top: 0; - padding-bottom: 0; -} - -<div> - <pre - class=" emotion-0 emotion-1" - > - <code - class="hljs" - > - <span - class="hljs-attr" - > - foo: - </span> - - <span - class="hljs-string" - > - bar - </span> - - - <span - class="hljs-attr" - > - pleh: - </span> - - <span - class="hljs-string" - > - help - </span> - - - <span - class="hljs-attr" - > - stuff: - </span> - - - <span - class="hljs-attr" - > - foo: - </span> - - <span - class="hljs-string" - > - bar - </span> - - - <span - class="hljs-attr" - > - bar: - </span> - - <span - class="hljs-string" - > - foo - </span> - </code> - </pre> -</div> -`; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 2724f1199d8..dbe05f21f67 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -26,7 +26,7 @@ export { Breadcrumbs } from './Breadcrumbs'; export * from './BubbleChart'; export * from './Card'; export * from './Checkbox'; -export * from './CodeSnippet'; +export * from './CodeSyntaxHighlighter'; export * from './ColorsLegend'; export * from './CoverageIndicator'; export * from './DatePicker'; diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx index 27c79da9e56..3a66d28122c 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsDescription.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 { CodeSyntaxHighlighter } from 'design-system'; import * as React from 'react'; import { updateRule } from '../../../api/rules'; import FormattingTips from '../../../components/common/FormattingTips'; @@ -46,8 +48,8 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S state: State = { description: '', descriptionForm: false, - submitting: false, removeDescriptionModal: false, + submitting: false, }; componentDidMount() { @@ -89,6 +91,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S }).then( (ruleDetails) => { this.props.onChange(ruleDetails); + if (this.mounted) { this.setState({ submitting: false, descriptionForm: false }); } @@ -104,7 +107,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S handleExtendDescriptionClick = () => { this.setState({ // set description` to the current `mdNote` each time the form is open - description: this.props.ruleDetails.mdNote || '', + description: this.props.ruleDetails.mdNote ?? '', descriptionForm: true, }); }; @@ -112,14 +115,13 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S renderExtendedDescription = () => ( <div id="coding-rules-detail-description-extra"> {this.props.ruleDetails.htmlNote !== undefined && ( - <div - className="rule-desc spacer-bottom markdown" - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ - __html: sanitizeUserInput(this.props.ruleDetails.htmlNote), - }} + <CodeSyntaxHighlighter + className="rule-desc markdown sw-mb-2" + htmlAsString={sanitizeUserInput(this.props.ruleDetails.htmlNote)} + language={this.props.ruleDetails.lang} /> )} + {this.props.canWrite && ( <Button id="coding-rules-detail-extend-description" @@ -147,6 +149,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S /> </td> </tr> + <tr> <td> <Button @@ -156,6 +159,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S > {translate('save')} </Button> + {this.props.ruleDetails.mdNote !== undefined && ( <> <Button @@ -174,6 +178,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S )} </> )} + <ResetButtonLink className="spacer-left" disabled={this.state.submitting} @@ -184,6 +189,7 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S </ResetButtonLink> {this.state.submitting && <i className="spinner spacer-left" />} </td> + <td className="text-right"> <FormattingTips /> </td> @@ -216,23 +222,24 @@ export default class RuleDetailsDescription extends React.PureComponent<Props, S return ( <div className="js-rule-description"> {defaultSection && ( - <section + <CodeSyntaxHighlighter className="coding-rules-detail-description markdown" key={defaultSection.key} - /* eslint-disable-next-line react/no-danger */ - dangerouslySetInnerHTML={{ __html: sanitizeString(defaultSection.content) }} + htmlAsString={sanitizeString(defaultSection.content)} + language={ruleDetails.lang} /> )} {hasDescriptionSection && !defaultSection && ( <> {introductionSection && ( - <div + <CodeSyntaxHighlighter className="rule-desc" - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: sanitizeString(introductionSection) }} + htmlAsString={sanitizeString(introductionSection)} + language={ruleDetails.lang} /> )} + <RuleTabViewer ruleDetails={ruleDetails} /> </> )} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx index 0a9e334bd08..e62c665ea42 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewer.tsx @@ -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 * as React from 'react'; import { getRuleDetails } from '../../../api/rules'; import { getSecurityHotspotDetails } from '../../../api/security-hotspots'; @@ -36,19 +37,20 @@ import HotspotViewerRenderer from './HotspotViewerRenderer'; interface Props { component: Component; hotspotKey: string; - onSwitchStatusFilter: (option: HotspotStatusFilter) => void; hotspotsReviewedMeasure?: string; - onUpdateHotspot: (hotspotKey: string) => Promise<void>; onLocationClick: (index: number) => void; + onSwitchStatusFilter: (option: HotspotStatusFilter) => void; + onUpdateHotspot: (hotspotKey: string) => Promise<void>; selectedHotspotLocation?: number; standards?: Standards; } interface State { hotspot?: Hotspot; - ruleDescriptionSections?: RuleDescriptionSection[]; lastStatusChangedTo?: HotspotStatusOption; loading: boolean; + ruleDescriptionSections?: RuleDescriptionSection[]; + ruleLanguage?: string; showStatusUpdateSuccessModal: boolean; } @@ -78,6 +80,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> { fetchHotspot = async () => { this.setState({ loading: true }); + try { const hotspot = await getSecurityHotspotDetails(this.props.hotspotKey); const ruleDetails = await getRuleDetails({ key: hotspot.rule.key }).then((r) => r.rule); @@ -86,6 +89,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> { this.setState({ hotspot, loading: false, + ruleLanguage: ruleDetails.lang, ruleDescriptionSections: ruleDetails.descriptionSections, }); } @@ -112,6 +116,7 @@ export default class HotspotViewer extends React.PureComponent<Props, State> { handleSwitchFilterToStatusOfUpdatedHotspot = () => { const { lastStatusChangedTo } = this.state; + if (lastStatusChangedTo) { this.props.onSwitchStatusFilter(getStatusFilterFromStatusOption(lastStatusChangedTo)); } @@ -122,10 +127,12 @@ export default class HotspotViewer extends React.PureComponent<Props, State> { }; render() { - const { component, selectedHotspotLocation, standards, hotspotsReviewedMeasure } = this.props; + const { component, hotspotsReviewedMeasure, selectedHotspotLocation, standards } = this.props; + const { hotspot, ruleDescriptionSections, + ruleLanguage, loading, showStatusUpdateSuccessModal, lastStatusChangedTo, @@ -133,19 +140,20 @@ export default class HotspotViewer extends React.PureComponent<Props, State> { return ( <HotspotViewerRenderer + component={component} + hotspot={hotspot} hotspotsReviewedMeasure={hotspotsReviewedMeasure} lastStatusChangedTo={lastStatusChangedTo} + loading={loading} onCloseStatusUpdateSuccessModal={this.handleCloseStatusUpdateSuccessModal} + onLocationClick={this.props.onLocationClick} onSwitchFilterToStatusOfUpdatedHotspot={this.handleSwitchFilterToStatusOfUpdatedHotspot} - showStatusUpdateSuccessModal={showStatusUpdateSuccessModal} - standards={standards} - component={component} - hotspot={hotspot} - ruleDescriptionSections={ruleDescriptionSections} - loading={loading} onUpdateHotspot={this.handleHotspotUpdate} - onLocationClick={this.props.onLocationClick} + ruleDescriptionSections={ruleDescriptionSections} + ruleLanguage={ruleLanguage} selectedHotspotLocation={selectedHotspotLocation} + showStatusUpdateSuccessModal={showStatusUpdateSuccessModal} + standards={standards} /> ); } diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx index 6e4ae9e6798..8aedbfe45d0 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx @@ -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 * as React from 'react'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; @@ -36,17 +37,17 @@ export interface HotspotViewerRendererProps { component: Component; currentUser: CurrentUser; hotspot?: Hotspot; - ruleDescriptionSections?: RuleDescriptionSection[]; hotspotsReviewedMeasure?: string; - onSwitchFilterToStatusOfUpdatedHotspot: () => void; lastStatusChangedTo?: HotspotStatusOption; - onCloseStatusUpdateSuccessModal: () => void; - showStatusUpdateSuccessModal: boolean; - loading: boolean; - onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>; + onCloseStatusUpdateSuccessModal: () => void; onLocationClick: (index: number) => void; + onSwitchFilterToStatusOfUpdatedHotspot: () => void; + onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>; + ruleDescriptionSections?: RuleDescriptionSection[]; + ruleLanguage?: string; selectedHotspotLocation?: number; + showStatusUpdateSuccessModal: boolean; standards?: Standards; } @@ -55,12 +56,13 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { component, currentUser, hotspot, + hotspotsReviewedMeasure, + lastStatusChangedTo, loading, - selectedHotspotLocation, ruleDescriptionSections, + ruleLanguage, + selectedHotspotLocation, showStatusUpdateSuccessModal, - hotspotsReviewedMeasure, - lastStatusChangedTo, standards, } = props; @@ -89,6 +91,7 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { onCommentUpdate={props.onUpdateHotspot} /> } + branchLike={branchLike} codeTabContent={ <HotspotSnippetContainer branchLike={branchLike} @@ -99,11 +102,11 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { /> } component={component} - standards={standards} - onUpdateHotspot={props.onUpdateHotspot} - branchLike={branchLike} hotspot={hotspot} + onUpdateHotspot={props.onUpdateHotspot} ruleDescriptionSections={ruleDescriptionSections} + ruleLanguage={ruleLanguage} + standards={standards} /> </div> )} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx index 23e62039ee8..2401739e699 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerTabs.tsx @@ -17,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 { ToggleButton, getTabId, getTabPanelId } from 'design-system'; import { groupBy, omit } from 'lodash'; import * as React from 'react'; @@ -34,18 +35,20 @@ import { HotspotHeader } from './HotspotHeader'; interface Props { activityTabContent: React.ReactNode; + branchLike?: BranchLike; codeTabContent: React.ReactNode; - hotspot: Hotspot; - ruleDescriptionSections?: RuleDescriptionSection[]; component: Component; - branchLike?: BranchLike; + hotspot: Hotspot; onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>; + ruleDescriptionSections?: RuleDescriptionSection[]; + ruleLanguage?: string; standards?: Standards; } + interface Tab { - value: TabKeys; - label: string; counter?: number; + label: string; + value: TabKeys; } export enum TabKeys { @@ -61,13 +64,14 @@ const STICKY_HEADER_COMPRESS_THRESHOLD = 200; export default function HotspotViewerTabs(props: Props) { const { - ruleDescriptionSections, - codeTabContent, activityTabContent, - hotspot, + branchLike, + codeTabContent, component, + hotspot, + ruleDescriptionSections, + ruleLanguage, standards, - branchLike, } = props; const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress( @@ -119,6 +123,7 @@ export default function HotspotViewerTabs(props: Props) { if (isInput(event) || isShortcut(event)) { return true; } + if (event.key === KeyboardKeys.LeftArrow) { event.preventDefault(); selectNeighboringTab(-1); @@ -143,6 +148,7 @@ export default function HotspotViewerTabs(props: Props) { const handleSelectTabs = (tabKey: TabKeys) => { const currentTab = tabs.find((tab) => tab.value === tabKey); + if (currentTab) { setCurrentTab(currentTab); } @@ -152,10 +158,12 @@ export default function HotspotViewerTabs(props: Props) { document.addEventListener('keydown', handleKeyboardNavigation); return () => document.removeEventListener('keydown', handleKeyboardNavigation); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); React.useEffect(() => { setCurrentTab(tabs[0]); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [hotspot.key]); React.useEffect(() => { @@ -163,9 +171,11 @@ export default function HotspotViewerTabs(props: Props) { window.scrollTo({ top: 0 }); } resetScrollDownCompress(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTab]); const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key); + const rootCauseDescriptionSections = descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]; @@ -173,13 +183,13 @@ export default function HotspotViewerTabs(props: Props) { return ( <> <HotspotHeader - hotspot={hotspot} - component={component} - standards={standards} - onUpdateHotspot={props.onUpdateHotspot} branchLike={branchLike} - isScrolled={isScrolled} + component={component} + hotspot={hotspot} isCompressed={isCompressed} + isScrolled={isScrolled} + onUpdateHotspot={props.onUpdateHotspot} + standards={standards} tabs={ <ToggleButton role="tablist" @@ -198,12 +208,13 @@ export default function HotspotViewerTabs(props: Props) { {currentTab.value === TabKeys.Code && codeTabContent} {currentTab.value === TabKeys.RiskDescription && rootCauseDescriptionSections && ( - <RuleDescription sections={rootCauseDescriptionSections} /> + <RuleDescription language={ruleLanguage} sections={rootCauseDescriptionSections} /> )} {currentTab.value === TabKeys.VulnerabilityDescription && descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( <RuleDescription + language={ruleLanguage} sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} /> )} @@ -211,6 +222,7 @@ export default function HotspotViewerTabs(props: Props) { {currentTab.value === TabKeys.FixRecommendation && descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( <RuleDescription + language={ruleLanguage} sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} /> )} diff --git a/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx index ca8be50d77f..28bb2f1ddf8 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx @@ -17,12 +17,13 @@ * 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 { screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { getTask } from '../../../api/ce'; import { mockTaskWarning } from '../../../helpers/mocks/tasks'; import { mockCurrentUser } from '../../../helpers/testMocks'; -import { waitAndUpdate } from '../../../helpers/testUtils'; +import { renderComponent } from '../../../helpers/testReactTestingUtils'; import { AnalysisWarningsModal } from '../AnalysisWarningsModal'; jest.mock('../../../api/ce', () => ({ @@ -34,48 +35,50 @@ jest.mock('../../../api/ce', () => ({ beforeEach(jest.clearAllMocks); -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot('default'); - expect(shallowRender({ warnings: [mockTaskWarning({ dismissable: true })] })).toMatchSnapshot( - 'with dismissable warnings' - ); - expect( - shallowRender({ +describe('should render correctly', () => { + it('should not show dismiss buttons for non-dismissable warnings', () => { + renderAnalysisWarningsModal(); + + expect(screen.getByText('warning 1')).toBeInTheDocument(); + expect(screen.getByText('warning 2')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'dismiss_permanently' })).not.toBeInTheDocument(); + }); + + it('should show a dismiss button for dismissable warnings', () => { + renderAnalysisWarningsModal({ warnings: [mockTaskWarning({ dismissable: true })] }); + + expect(screen.getByRole('button', { name: 'dismiss_permanently' })).toBeInTheDocument(); + }); + + it('should not show dismiss buttons if not logged in', () => { + renderAnalysisWarningsModal({ currentUser: mockCurrentUser({ isLoggedIn: false }), warnings: [mockTaskWarning({ dismissable: true })], - }) - ).toMatchSnapshot('do not show dismissable links for anonymous'); + }); + + expect(screen.queryByRole('button', { name: 'dismiss_permanently' })).not.toBeInTheDocument(); + }); }); it('should not fetch task warnings if it does not have to', () => { - shallowRender(); + renderAnalysisWarningsModal(); + expect(getTask).not.toHaveBeenCalled(); }); it('should fetch task warnings if it has to', async () => { - const wrapper = shallowRender({ taskId: 'abcd1234', warnings: undefined }); - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); - expect(getTask).toHaveBeenCalledWith('abcd1234', ['warnings']); -}); + renderAnalysisWarningsModal({ taskId: 'abcd1234', warnings: undefined }); -it('should correctly handle updates', async () => { - const wrapper = shallowRender(); - - await waitAndUpdate(wrapper); - expect(getTask).not.toHaveBeenCalled(); - - wrapper.setProps({ taskId: '1', warnings: undefined }); - await waitAndUpdate(wrapper); - expect(getTask).toHaveBeenCalled(); + expect(screen.queryByText('message foo')).not.toBeInTheDocument(); + expect(getTask).toHaveBeenCalledWith('abcd1234', ['warnings']); - (getTask as jest.Mock).mockClear(); - wrapper.setProps({ taskId: undefined, warnings: [mockTaskWarning()] }); - expect(getTask).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.getByText('message foo')).toBeInTheDocument(); + }); }); -function shallowRender(props: Partial<AnalysisWarningsModal['props']> = {}) { - return shallow<AnalysisWarningsModal>( +function renderAnalysisWarningsModal(props: Partial<AnalysisWarningsModal['props']> = {}) { + return renderComponent( <AnalysisWarningsModal currentUser={mockCurrentUser({ isLoggedIn: true })} onClose={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/AnalysisWarningsModal-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/AnalysisWarningsModal-test.tsx.snap deleted file mode 100644 index d7393372849..00000000000 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/AnalysisWarningsModal-test.tsx.snap +++ /dev/null @@ -1,216 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should fetch task warnings if it has to 1`] = ` -<Gl - body={ - <DeferredSpinner - loading={false} - > - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "message foo", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "message-bar", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "multiline message<br>secondline<br> third line", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - </DeferredSpinner> - } - headerTitle="warnings" - onClose={[MockFunction]} - primaryButton={null} - secondaryButtonLabel="close" -/> -`; - -exports[`should render correctly: default 1`] = ` -<Gl - body={ - <DeferredSpinner - loading={false} - > - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "warning 1", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "warning 2", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - </DeferredSpinner> - } - headerTitle="warnings" - onClose={[MockFunction]} - primaryButton={null} - secondaryButtonLabel="close" -/> -`; - -exports[`should render correctly: do not show dismissable links for anonymous 1`] = ` -<Gl - body={ - <DeferredSpinner - loading={false} - > - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "Lorem ipsum", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div /> - </React.Fragment> - </DeferredSpinner> - } - headerTitle="warnings" - onClose={[MockFunction]} - primaryButton={null} - secondaryButtonLabel="close" -/> -`; - -exports[`should render correctly: with dismissable warnings 1`] = ` -<Gl - body={ - <DeferredSpinner - loading={false} - > - <React.Fragment> - <div - className="sw-flex sw-items-center sw-mt-2" - > - <FlagMessage - variant="warning" - > - <HtmlFormatter> - <span - dangerouslySetInnerHTML={ - { - "__html": "Lorem ipsum", - } - } - /> - </HtmlFormatter> - </FlagMessage> - </div> - <div> - <div - className="sw-mt-4" - > - <DangerButtonSecondary - disabled={false} - onClick={[Function]} - > - dismiss_permanently - </DangerButtonSecondary> - <DeferredSpinner - className="sw-ml-2" - loading={false} - /> - </div> - </div> - </React.Fragment> - </DeferredSpinner> - } - headerTitle="warnings" - onClose={[MockFunction]} - primaryButton={null} - secondaryButtonLabel="close" -/> -`; diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx index 952979cba94..b9b7e58c7bd 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -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 classNames from 'classnames'; import { ToggleButton } from 'design-system'; import { cloneDeep, debounce, groupBy } from 'lodash'; @@ -84,7 +85,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta constructor(props: IssueTabViewerProps) { super(props); + this.educationPrinciplesRef = React.createRef(); + this.checkIfEducationPrinciplesAreVisible = debounce( this.checkIfEducationPrinciplesAreVisible, DEBOUNCE_FOR_SCROLL @@ -98,6 +101,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta const tabs = this.computeTabs(Boolean(this.state.displayEducationalPrinciplesNotification)); const query = new URLSearchParams(this.props.location.search); + if (query.has('why')) { this.setState({ selectedTab: tabs.find((tab) => tab.key === TabKeys.WhyIsThisAnIssue) || tabs[0], @@ -114,6 +118,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta selectedFlowIndex, selectedLocationIndex, } = this.props; + const { selectedTab } = this.state; if ( @@ -163,6 +168,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta ruleDetails.educationPrinciples.length > 0 && isLoggedIn && !dismissedNotices[NoticeType.EDUCATION_PRINCIPLES]; + const tabs = this.computeTabs(displayEducationalPrinciplesNotification); return { @@ -175,7 +181,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta computeTabs = (displayEducationalPrinciplesNotification: boolean) => { const { codeTabContent, - ruleDetails: { descriptionSections, educationPrinciples, type: ruleType }, + ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, ruleDescriptionContextKey, extendedDescription, activityTabContent, @@ -214,11 +220,12 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]) && ( <RuleDescription + defaultContextKey={ruleDescriptionContextKey} + language={ruleLanguage} sections={ descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE] } - defaultContextKey={ruleDescriptionContextKey} /> ), }, @@ -228,6 +235,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( <RuleDescription + language={ruleLanguage} sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} /> ), @@ -238,8 +246,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( <RuleDescription - sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} defaultContextKey={ruleDescriptionContextKey} + language={ruleLanguage} + sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} /> ), }, @@ -257,10 +266,11 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta content: ((educationPrinciples && educationPrinciples.length > 0) || descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( <MoreInfoRuleDescription - educationPrinciples={educationPrinciples} - sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} displayEducationalPrinciplesNotification={displayEducationalPrinciplesNotification} + educationPrinciples={educationPrinciples} educationPrinciplesRef={this.educationPrinciplesRef} + language={ruleLanguage} + sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} /> ), counter: displayEducationalPrinciplesNotification ? 1 : undefined, @@ -346,7 +356,8 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta {({ top }) => ( <div style={{ - // We substract the footer height with padding (80) and the main layout padding (20) and the tabs padding (20) + // We substract the footer height with padding (80) and the main layout padding (20) + // and the tabs padding (20) maxHeight: scrollInTab ? `calc(100vh - ${top + 120}px)` : 'initial', }} className="sw-flex sw-flex-col" diff --git a/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx b/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx index d134501fb5c..1b5f3e25215 100644 --- a/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx +++ b/server/sonar-web/src/main/js/components/rules/MoreInfoRuleDescription.tsx @@ -17,31 +17,35 @@ * 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 { RuleDescriptionSection } from '../../apps/coding-rules/rule'; import { translate } from '../../helpers/l10n'; import { Dict } from '../../types/types'; import { ButtonLink } from '../controls/buttons'; import { Alert } from '../ui/Alert'; +import RuleDescription from './RuleDescription'; import DefenseInDepth from './educationPrinciples/DefenseInDepth'; import NeverTrustUserInput from './educationPrinciples/NeverTrustUserInput'; -import RuleDescription from './RuleDescription'; import './style.css'; interface Props { - sections?: RuleDescriptionSection[]; - educationPrinciples?: string[]; displayEducationalPrinciplesNotification?: boolean; + educationPrinciples?: string[]; educationPrinciplesRef?: React.RefObject<HTMLDivElement>; + language?: string; + sections?: RuleDescriptionSection[]; } const EDUCATION_PRINCIPLES_MAP: Dict<React.ComponentType> = { defense_in_depth: DefenseInDepth, never_trust_user_input: NeverTrustUserInput, }; + export default class MoreInfoRuleDescription extends React.PureComponent<Props, {}> { handleNotificationScroll = () => { const element = this.props.educationPrinciplesRef?.current; + if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); } @@ -50,10 +54,12 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, render() { const { displayEducationalPrinciplesNotification, + language, sections = [], educationPrinciples = [], educationPrinciplesRef, } = this.props; + return ( <div className="padded rule-desc"> {displayEducationalPrinciplesNotification && ( @@ -61,6 +67,7 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, <p className="little-spacer-bottom little-spacer-top"> {translate('coding_rules.more_info.notification_message')} </p> + <ButtonLink onClick={() => { this.handleNotificationScroll(); @@ -70,10 +77,11 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, </ButtonLink> </Alert> )} + {sections.length > 0 && ( <> <h2>{translate('coding_rules.more_info.resources.title')}</h2> - <RuleDescription sections={sections} /> + <RuleDescription language={language} sections={sections} /> </> )} @@ -82,11 +90,14 @@ export default class MoreInfoRuleDescription extends React.PureComponent<Props, <h2 ref={educationPrinciplesRef}> {translate('coding_rules.more_info.education_principles.title')} </h2> + {educationPrinciples.map((key) => { const Concept = EDUCATION_PRINCIPLES_MAP[key]; + if (Concept === undefined) { return null; } + return ( <div key={key} className="education-principles big-spacer-top big-padded"> <Concept /> diff --git a/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx b/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx index 9f8a6d41a41..7cd177c3fa1 100644 --- a/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx +++ b/server/sonar-web/src/main/js/components/rules/RuleDescription.tsx @@ -17,8 +17,16 @@ * 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 { FlagMessage, HtmlFormatter, themeBorder, themeColor, ToggleButton } from 'design-system'; +import { + CodeSyntaxHighlighter, + FlagMessage, + HtmlFormatter, + ToggleButton, + themeBorder, + themeColor, +} from 'design-system'; import * as React from 'react'; import { RuleDescriptionSection } from '../../apps/coding-rules/rule'; import applyCodeDifferences from '../../helpers/code-difference'; @@ -29,9 +37,10 @@ import OtherContextOption from './OtherContextOption'; const OTHERS_KEY = 'others'; interface Props { - sections: RuleDescriptionSection[]; - defaultContextKey?: string; className?: string; + defaultContextKey?: string; + language?: string; + sections: RuleDescriptionSection[]; } interface State { @@ -102,13 +111,14 @@ export default class RuleDescription extends React.PureComponent<Props, State> { const { contexts } = this.state; const selected = contexts.find((ctxt) => ctxt.displayName === value); + if (selected) { this.setState({ selectedContext: selected }); } }; render() { - const { className, sections } = this.props; + const { className, language, sections } = this.props; const { contexts, defaultContext, selectedContext } = this.state; const options = contexts.map((ctxt) => ({ @@ -127,6 +137,7 @@ export default class RuleDescription extends React.PureComponent<Props, State> { <h2 className="sw-body-sm-highlight sw-mb-4"> {translate('coding_rules.description_context.title')} </h2> + {defaultContext && ( <FlagMessage variant="info" className="sw-mb-4"> {translateWithParameters( @@ -135,6 +146,7 @@ export default class RuleDescription extends React.PureComponent<Props, State> { )} </FlagMessage> )} + <div className="sw-mb-4"> <ToggleButton label={translate('coding_rules.description_context.title')} @@ -142,6 +154,7 @@ export default class RuleDescription extends React.PureComponent<Props, State> { options={options} value={selectedContext.displayName} /> + {selectedContext.key !== OTHERS_KEY && ( <h2> {translateWithParameters( @@ -151,12 +164,13 @@ export default class RuleDescription extends React.PureComponent<Props, State> { </h2> )} </div> + {selectedContext.key === OTHERS_KEY ? ( <OtherContextOption /> ) : ( - <div - /* eslint-disable-next-line react/no-danger */ - dangerouslySetInnerHTML={{ __html: sanitizeString(selectedContext.content) }} + <CodeSyntaxHighlighter + htmlAsString={sanitizeString(selectedContext.content)} + language={language} /> )} </StyledHtmlFormatter> @@ -169,11 +183,12 @@ export default class RuleDescription extends React.PureComponent<Props, State> { ref={(node: HTMLDivElement) => { applyCodeDifferences(node); }} - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ - __html: sanitizeString(sections[0].content), - }} - /> + > + <CodeSyntaxHighlighter + htmlAsString={sanitizeString(sections[0].content)} + language={language} + /> + </StyledHtmlFormatter> ); } } diff --git a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx index 33528e4f61c..fa7ea48c6b9 100644 --- a/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/RuleTabViewer.tsx @@ -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 classNames from 'classnames'; import { cloneDeep, debounce, groupBy } from 'lodash'; import * as React from 'react'; @@ -82,7 +83,9 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State constructor(props: RuleTabViewerProps) { super(props); + this.educationPrinciplesRef = React.createRef(); + this.checkIfEducationPrinciplesAreVisible = debounce( this.checkIfEducationPrinciplesAreVisible, DEBOUNCE_FOR_SCROLL @@ -96,6 +99,7 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State const tabs = this.computeTabs(Boolean(this.state.displayEducationalPrinciplesNotification)); const query = new URLSearchParams(this.props.location.search); + if (query.has('why')) { this.setState({ selectedTab: tabs.find((tab) => tab.key === TabKeys.WhyIsThisAnIssue) ?? tabs[0], @@ -112,6 +116,7 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State selectedFlowIndex, selectedLocationIndex, } = this.props; + const { selectedTab } = this.state; if ( @@ -161,6 +166,7 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State ruleDetails.educationPrinciples.length > 0 && isLoggedIn && !dismissedNotices[NoticeType.EDUCATION_PRINCIPLES]; + const tabs = this.computeTabs(displayEducationalPrinciplesNotification); return { @@ -173,7 +179,7 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State computeTabs = (displayEducationalPrinciplesNotification: boolean) => { const { codeTabContent, - ruleDetails: { descriptionSections, educationPrinciples, type: ruleType }, + ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, ruleDescriptionContextKey, extendedDescription, activityTabContent, @@ -193,8 +199,8 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State } else { descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [ { - key: RuleDescriptionSections.RESOURCES, content: extendedDescription, + key: RuleDescriptionSections.RESOURCES, }, ]; } @@ -202,74 +208,78 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State const tabs: Tab[] = [ { - key: TabKeys.WhyIsThisAnIssue, - label: - ruleType === 'SECURITY_HOTSPOT' - ? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') - : translate('coding_rules.description_section.title.root_cause'), content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]) && ( <RuleDescription className="padded" + defaultContextKey={ruleDescriptionContextKey} + language={ruleLanguage} sections={ descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] || descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE] } - defaultContextKey={ruleDescriptionContextKey} /> ), + key: TabKeys.WhyIsThisAnIssue, + label: + ruleType === 'SECURITY_HOTSPOT' + ? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT') + : translate('coding_rules.description_section.title.root_cause'), }, { - key: TabKeys.AssessTheIssue, - label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && ( <RuleDescription className="padded" + language={ruleLanguage} sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]} /> ), + key: TabKeys.AssessTheIssue, + label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue), }, { - key: TabKeys.HowToFixIt, - label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && ( <RuleDescription className="padded" - sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} defaultContextKey={ruleDescriptionContextKey} + language={ruleLanguage} + sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]} /> ), + key: TabKeys.HowToFixIt, + label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt), }, { + content: activityTabContent, key: TabKeys.Activity, label: translate('coding_rules.description_section.title', TabKeys.Activity), - content: activityTabContent, }, { - key: TabKeys.MoreInfo, - label: ( - <> - {translate('coding_rules.description_section.title', TabKeys.MoreInfo)} - {displayEducationalPrinciplesNotification && <div className="notice-dot" />} - </> - ), content: ((educationPrinciples && educationPrinciples.length > 0) || descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && ( <MoreInfoRuleDescription - educationPrinciples={educationPrinciples} - sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} displayEducationalPrinciplesNotification={displayEducationalPrinciplesNotification} + educationPrinciples={educationPrinciples} educationPrinciplesRef={this.educationPrinciplesRef} + language={ruleLanguage} + sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]} /> ), + key: TabKeys.MoreInfo, + label: ( + <> + {translate('coding_rules.description_section.title', TabKeys.MoreInfo)} + {displayEducationalPrinciplesNotification && <div className="notice-dot" />} + </> + ), }, ]; if (codeTabContent !== undefined) { tabs.unshift({ + content: codeTabContent, key: TabKeys.Code, label: translate('issue.tabs', TabKeys.Code), - content: codeTabContent, }); } @@ -342,14 +352,14 @@ export class RuleTabViewer extends React.PureComponent<RuleTabViewerProps, State <ScreenPositionHelper> {({ top }) => ( <div + aria-labelledby={getTabId(selectedTab.key)} + className="bordered display-flex-column" + id={getTabPanelId(selectedTab.key)} + role="tabpanel" style={{ // We substract the footer height with padding (80) and the main layout padding (20) maxHeight: scrollInTab ? `calc(100vh - ${top + 100}px)` : 'initial', }} - className="bordered display-flex-column" - role="tabpanel" - aria-labelledby={getTabId(selectedTab.key)} - id={getTabPanelId(selectedTab.key)} > { // Preserve tabs state by always rendering all of them. Only hide them when not selected diff --git a/server/sonar-web/src/main/js/helpers/mocks/tasks.ts b/server/sonar-web/src/main/js/helpers/mocks/tasks.ts index 96974fb3ca3..b15212b8d65 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/tasks.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/tasks.ts @@ -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 { uniqueId } from 'lodash'; import { ComponentQualifier } from '../../types/component'; import { Task, TaskStatuses, TaskTypes, TaskWarning } from '../../types/tasks'; @@ -36,7 +38,7 @@ export function mockTask(overrides: Partial<Task> = {}): Task { export function mockTaskWarning(overrides: Partial<TaskWarning> = {}): TaskWarning { return { - key: 'foo', + key: uniqueId('foo'), message: 'Lorem ipsum', dismissable: false, ...overrides, diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock index 0caa169d4e6..5c9ee44f84d 100644 --- a/server/sonar-web/yarn.lock +++ b/server/sonar-web/yarn.lock @@ -6173,6 +6173,9 @@ __metadata: eslint-plugin-local-rules: 1.3.2 eslint-plugin-typescript-sort-keys: 2.3.0 highlight.js: 11.7.0 + highlightjs-apex: 1.2.0 + highlightjs-cobol: 0.3.3 + highlightjs-sap-abap: 0.2.0 history: 5.3.0 jest: 29.5.0 postcss: 8.4.21 @@ -7877,6 +7880,30 @@ __metadata: languageName: node linkType: hard +"highlightjs-apex@npm:1.2.0": + version: 1.2.0 + resolution: "highlightjs-apex@npm:1.2.0" + checksum: d0e1543dbdfa156c0bb6da74afcb7aaf315fe211024246917c8f967075d6c239bcd408d6a667c324ea75b928a2d8495cb1b8d160b403894aea8c9a16c293f33a + languageName: node + linkType: hard + +"highlightjs-cobol@npm:0.3.3": + version: 0.3.3 + resolution: "highlightjs-cobol@npm:0.3.3" + dependencies: + minimist: ">=1.2.6" + mkdirp: ^1.0.4 + checksum: f59a694703f883ead2fbdf262f36eab583c8bbf64649e59d5de1e13a43e43979ad9030eab69e1cb08f7558ce60ca5cfd1fe42c7a38e34fd8b0baebf06df9118d + languageName: node + linkType: hard + +"highlightjs-sap-abap@npm:0.2.0": + version: 0.2.0 + resolution: "highlightjs-sap-abap@npm:0.2.0" + checksum: 685293fc2de3b333d0166f7323d38b53b70a53a462851d075484826c7ee16fa09f4c24cda44c1c957525352e112ba0a83cefe37641ee65b62e1483be93c60019 + languageName: node + linkType: hard + "history@npm:5.3.0": version: 5.3.0 resolution: "history@npm:5.3.0" @@ -9816,6 +9843,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:>=1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 75a6d645fb122dad29c06a7597bddea977258957ed88d7a6df59b5cd3fe4a527e253e9bbf2e783e4b73657f9098b96a5fe96ab8a113655d4109108577ecf85b0 + languageName: node + linkType: hard + "minimist@npm:^1.2.0, minimist@npm:^1.2.5": version: 1.2.5 resolution: "minimist@npm:1.2.5" |