From 488359051365bb3c7bfc5c6b6c652d2b6f3919ec Mon Sep 17 00:00:00 2001 From: 7PH Date: Tue, 30 Jul 2024 10:32:48 +0200 Subject: [PATCH] NO-JIRA Validation fixes Co-authored-by: Lucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com> --- .../src/components/CodeSyntaxHighlighter.tsx | 2 +- .../components/__tests__/LineFinding-test.tsx | 7 +- .../__snapshots__/LineFinding-test.tsx.snap | 2 +- .../src/components/code-line/LineFinding.tsx | 4 +- .../hljs/HljsIssueIndicatorPlugin.ts | 23 +- .../sonar-aligned/hljs/HljsUnderlinePlugin.ts | 10 + .../js/app/components/SonarLintConnection.tsx | 2 +- .../nav/component/branch-like/PRLink.tsx | 2 +- .../nav/global/MainSonarQubeBar.tsx | 2 +- .../PromotionNotification.tsx | 2 +- .../account/profile/UserExternalIdentity.tsx | 2 +- .../main/js/apps/code/__tests__/Code-it.ts | 6 +- .../code/components/SourceViewerWrapper.tsx | 41 ++-- .../project/CreateProjectModeSelection.tsx | 2 +- .../js/apps/groups/components/ListItem.tsx | 2 +- .../__tests__/IssuesSourceViewer-it.tsx | 38 ++- .../issues/components/IssuesSourceViewer.tsx | 20 +- .../JupyterNotebookIssueViewer.tsx | 217 +++++++++--------- .../js/apps/issues/jupyter-notebook/types.ts | 36 --- .../js/apps/issues/jupyter-notebook/utils.ts | 40 ---- .../marketplace/components/EditionBox.tsx | 2 +- .../branches/MeasuresPanelNoNewCode.tsx | 2 +- .../project/components/PageHeader.tsx | 2 +- .../badges/ProjectBadges.tsx | 2 +- .../components/ProjectCreationMenuItem.tsx | 2 +- .../components/EmptyHotspotsPage.tsx | 2 +- .../js/apps/sessions/components/Login.tsx | 2 +- .../almIntegration/AlmIntegrationRenderer.tsx | 2 +- .../authentication/Authentication.tsx | 2 +- .../users/components/UserListItemIdentity.tsx | 2 +- .../SourceViewer/SourceViewerPreview.tsx | 144 ++++++------ .../embed-docs-modal/EmbedDocsPopup.tsx | 2 +- .../js/components/permissions/GroupHolder.tsx | 2 +- .../js/components/permissions/UserHolder.tsx | 2 +- .../tutorials/TutorialSelectionRenderer.tsx | 2 +- .../GithubCFamilyExampleRepositories.tsx | 2 +- .../sonar-web/src/main/js/queries/sources.ts | 28 +-- .../SourceViewer/JupyterNotebookViewer.tsx | 22 +- .../components/common/Image.tsx | 2 +- .../common/__tests__/Image-test.tsx | 54 +++++ .../helpers/__tests__/component-test.ts | 10 +- .../__tests__/json-issue-mapper-test.ts | 26 ++- .../helpers/json-issue-mapper.ts | 58 ++++- 43 files changed, 452 insertions(+), 382 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/issues/jupyter-notebook/types.ts delete mode 100644 server/sonar-web/src/main/js/apps/issues/jupyter-notebook/utils.ts rename server/sonar-web/src/main/js/{ => sonar-aligned}/components/common/Image.tsx (96%) create mode 100644 server/sonar-web/src/main/js/sonar-aligned/components/common/__tests__/Image-test.tsx diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx index 1a35a36d10a..100bcbe6ee8 100644 --- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -158,7 +158,7 @@ const StyledSpan = styled.span` } .sonar-underline { - text-decoration: underline ${themeColor('codeLineIssueSquiggle')}; + text-decoration: underline ${themeColor('codeLineIssueSquiggle')}; // Fallback text-decoration: underline ${themeColor('codeLineIssueSquiggle')} wavy; text-decoration-thickness: 2px; text-decoration-skip-ink: none; diff --git a/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx b/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx index c93de7bd74c..20095ec4b94 100644 --- a/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx +++ b/server/sonar-web/design-system/src/components/__tests__/LineFinding-test.tsx @@ -23,13 +23,18 @@ import { render } from '../../helpers/testUtils'; import { FCProps } from '../../types/misc'; import { LineFinding } from '../code-line/LineFinding'; -it('should render correctly', async () => { +it('should render correctly as button', async () => { const user = userEvent.setup(); const { container } = setupWithProps(); await user.click(screen.getByRole('button')); expect(container).toMatchSnapshot(); }); +it('should render as non-button', () => { + setupWithProps({ as: 'div' }); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); +}); + it('should be clickable when onIssueSelect is provided', async () => { const mockClick = jest.fn(); const user = userEvent.setup(); diff --git a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap index e1b8dfa7431..d4ef5c08bf2 100644 --- a/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap +++ b/server/sonar-web/design-system/src/components/__tests__/__snapshots__/LineFinding-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render correctly 1`] = ` +exports[`should render correctly as button 1`] = ` .emotion-0 { all: unset; cursor: pointer; diff --git a/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx b/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx index 0d5eda10f6d..5169279dacf 100644 --- a/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx +++ b/server/sonar-web/design-system/src/components/code-line/LineFinding.tsx @@ -24,6 +24,7 @@ import { themeBorder, themeColor, themeContrast, themeShadow } from '../../helpe import { BareButton } from '../../sonar-aligned/components/buttons'; interface Props { + as?: React.ElementType; className?: string; issueKey: string; message: React.ReactNode; @@ -32,11 +33,12 @@ interface Props { } function LineFindingFunc( - { message, issueKey, selected = true, className, onIssueSelect }: Props, + { as, message, issueKey, selected = true, className, onIssueSelect }: Props, ref: Ref, ) { return ( { diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts index 08d32b3468e..782a23d1bea 100644 --- a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts @@ -22,6 +22,15 @@ import { BeforeHighlightContext, HighlightResult } from 'highlight.js'; const BREAK_LINE_REGEXP = /\n/g; +/** + * Plugin for HLJS to add issue indicators to the code. + * + * In order to add issue indicators (sidebar with indicators to issue count on each line), the input source code must be preprocessed using the addIssuesToLines() method. This method will update the source code to prepend the list of issues key of this line. + * Then, the `before` hook of the HLJS plugin will extract the issue keys from the source code, store them in memory for later use, and drop the issue keys token from the source code. + * Finally, the `after` hook will add some HTML markup with a reference to the issue key in the transformed code source. The actual interactive issue indicators will be attached inside this HTML markup later on with a React Portal. + * + * Each line is wrapped with a div element that contains the issue indicators and the line content. + */ export class HljsIssueIndicatorPlugin { static readonly LINE_WRAPPER_STYLE = [ 'display: inline-grid', @@ -30,7 +39,7 @@ export class HljsIssueIndicatorPlugin { 'align-items: center', ].join(';'); - private issueKeys: { [key: string]: string[] }; + private issueKeys: { [line: string]: string[] }; static readonly LINE_WRAPPER_OPEN_TAG = `
`; static readonly LINE_WRAPPER_CLOSE_TAG = `
`; static readonly EMPTY_INDICATOR_COLUMN = `
`; @@ -77,15 +86,15 @@ export class HljsIssueIndicatorPlugin { const issueKeysPattern = /\[ISSUE_KEYS:([^\]]+)\](.+)/; const removeIssueKeysPattern = /\[ISSUE_KEYS:[^\]]+\](.+)/; - const wrappedLines = lines.map((line, index) => { + const wrappedLines = lines.map((line, lineNumber) => { const match = issueKeysPattern.exec(line); if (match) { const issueKeys = match[1].split(','); - if (!this.issueKeys[index]) { - this.issueKeys[index] = issueKeys; + if (!this.issueKeys[lineNumber]) { + this.issueKeys[lineNumber] = issueKeys; } else { - this.issueKeys[index].push(...issueKeys); + this.issueKeys[lineNumber].push(...issueKeys); } } @@ -100,8 +109,8 @@ export class HljsIssueIndicatorPlugin { private addIssueIndicator(inputHtml: string) { const lines = this.getLines(inputHtml); - const wrappedLines = lines.map((line, index) => { - const issueKeys = this.issueKeys[index]; + const wrappedLines = lines.map((line, lineNumber) => { + const issueKeys = this.issueKeys[lineNumber]; if (issueKeys) { // the react portal looks for the first issue key diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts index 8bbead1f742..519e3d9b0b1 100644 --- a/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsUnderlinePlugin.ts @@ -29,6 +29,12 @@ interface UnderlineRange { start: UnderlineRangePosition; } +/** + * Plugin for HLJS to underline content. + * + * In order to underline code, the input source code must be preprocessed using the tokenize() method. + * Then, the after hook will replace the tokens with the appropriate HTML markup to underline the content. + */ export class HljsUnderlinePlugin { static readonly SPAN_REGEX = '<\\/?span[^>]*>'; @@ -119,6 +125,10 @@ export class HljsUnderlinePlugin { return source; } + /** + * Replace the tokens with the appropriate HTML markup to underline the content. + * Tokens were added using the tokenize() method. + */ 'after:highlight'(result: HighlightResult) { const re = new RegExp(HljsUnderlinePlugin.TOKEN_START, 'g'); re.lastIndex = 0; diff --git a/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx b/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx index f9de9cb57c0..cbe15716b5e 100644 --- a/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx +++ b/server/sonar-web/src/main/js/app/components/SonarLintConnection.tsx @@ -32,7 +32,7 @@ import { import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { useSearchParams } from 'react-router-dom'; -import { Image } from '../../components/common/Image'; +import { Image } from '~sonar-aligned/components/common/Image'; import { whenLoggedIn } from '../../components/hoc/whenLoggedIn'; import { translate, translateWithParameters } from '../../helpers/l10n'; import { generateSonarLintUserToken, portIsValid, sendUserToken } from '../../helpers/sonarlint'; diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/PRLink.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/PRLink.tsx index 189e7769c1d..a55d7576d50 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/PRLink.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/PRLink.tsx @@ -20,8 +20,8 @@ import { LinkStandalone } from '@sonarsource/echoes-react'; import React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { isPullRequest } from '~sonar-aligned/helpers/branch-like'; -import { Image } from '../../../../../components/common/Image'; import { translate, translateWithParameters } from '../../../../../helpers/l10n'; import { isDefined } from '../../../../../helpers/types'; import { AlmKeys } from '../../../../../types/alm-settings'; diff --git a/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx b/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx index 3ecb9d32f1b..96fbcf8a690 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/MainSonarQubeBar.tsx @@ -20,7 +20,7 @@ import { MainAppBar, SonarQubeLogo } from 'design-system'; import * as React from 'react'; -import { Image } from '../../../../components/common/Image'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate } from '../../../../helpers/l10n'; import { GlobalSettingKeys } from '../../../../types/settings'; import { AppStateContext } from '../../app-state/AppStateContext'; diff --git a/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx b/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx index fee386277f5..e66abae6eb9 100644 --- a/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx +++ b/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx @@ -22,8 +22,8 @@ import styled from '@emotion/styled'; import { Button } from '@sonarsource/echoes-react'; import { ButtonPrimary, themeBorder, themeColor } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { dismissNotice } from '../../../api/users'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; import { NoticeType, isLoggedIn } from '../../../types/users'; import { CurrentUserContextInterface } from '../current-user/CurrentUserContext'; diff --git a/server/sonar-web/src/main/js/apps/account/profile/UserExternalIdentity.tsx b/server/sonar-web/src/main/js/apps/account/profile/UserExternalIdentity.tsx index 058bad865d5..9e0bf59e04f 100644 --- a/server/sonar-web/src/main/js/apps/account/profile/UserExternalIdentity.tsx +++ b/server/sonar-web/src/main/js/apps/account/profile/UserExternalIdentity.tsx @@ -20,9 +20,9 @@ import { getTextColor } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { getIdentityProviders } from '../../../api/users'; import { colors } from '../../../app/theme'; -import { Image } from '../../../components/common/Image'; import { IdentityProvider } from '../../../types/types'; import { LoggedInUser } from '../../../types/users'; diff --git a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts index 0bc8b5494ab..0f59e34a416 100644 --- a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts +++ b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts @@ -551,7 +551,7 @@ function getPageObject(user: UserEvent) { byText(`code_viewer.no_source_code_displayed_due_to_empty_analysis.${qualifier}`), searchInput: byRole('searchbox'), previewToggle: byRole('radiogroup'), - previewToggleOption: (name: string = 'Preview') => + previewToggleOption: (name: string = 'preview') => byRole('radio', { name, }), @@ -604,10 +604,10 @@ function getPageObject(user: UserEvent) { await user.click(screen.getByRole('link', { name })); }, async clickToggleCode() { - await user.click(ui.previewToggleOption('Code').get()); + await user.click(ui.previewToggleOption('code').get()); }, async clickTogglePreview() { - await user.click(ui.previewToggleOption('Preview').get()); + await user.click(ui.previewToggleOption('preview').get()); }, async clickIssueIndicator() { await user.click(ui.previewIssueIndicator.get()); diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx index 219423b9c49..71c9840d0f2 100644 --- a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx @@ -17,12 +17,14 @@ * 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 } from 'design-system/lib'; +import { Spinner } from '@sonarsource/echoes-react'; +import { ToggleButton } from 'design-system'; import * as React from 'react'; import { Location } from '~sonar-aligned/types/router'; import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation'; import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import SourceViewerPreview from '../../../components/SourceViewer/SourceViewerPreview'; +import { translate } from '../../../helpers/l10n'; import { BranchLike } from '../../../types/branch-like'; import { Measure } from '../../../types/types'; @@ -44,11 +46,13 @@ function SourceViewerWrapper(props: SourceViewerWrapperProps) { ); const [tab, setTab] = React.useState('preview'); + const [isLoading, setIsLoading] = React.useState(false); const { line } = location.query; const finalLine = line ? Number(line) : undefined; const handleLoaded = React.useCallback(() => { + setIsLoading(false); if (line) { const row = document.querySelector(`.it__source-line-code[data-line-number="${line}"]`); if (row) { @@ -57,31 +61,40 @@ function SourceViewerWrapper(props: SourceViewerWrapperProps) { } }, [line]); + const handleTabChange = React.useCallback((value: string) => { + setTab(value); + if (value === 'code') { + setIsLoading(true); + } + }, []); + return isPreviewSupported ? ( <>
setTab(value)} + onChange={(value) => handleTabChange(value)} />
- {tab === 'preview' ? ( ) : ( - + <> + + + )} ) : ( diff --git a/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx b/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx index c613b28c7dd..ebb236a41a2 100644 --- a/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx +++ b/server/sonar-web/src/main/js/apps/create/project/CreateProjectModeSelection.tsx @@ -30,9 +30,9 @@ import { Title, } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import HelpTooltip from '~sonar-aligned/components/controls/HelpTooltip'; import withAppStateContext from '../../../app/components/app-state/withAppStateContext'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; import { getCreateProjectModeLocation } from '../../../helpers/urls'; import { AlmKeys } from '../../../types/alm-settings'; diff --git a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx index 9ddd4b52803..13e0be93493 100644 --- a/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx +++ b/server/sonar-web/src/main/js/apps/groups/components/ListItem.tsx @@ -34,7 +34,7 @@ import { } from 'design-system'; import * as React from 'react'; import { useState } from 'react'; -import { Image } from '../../../components/common/Image'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { useGroupMembersCountQuery } from '../../../queries/group-memberships'; import { Group, Provider } from '../../../types/types'; 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 6b220276678..1cac63aa8ba 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,10 +19,11 @@ */ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { keyBy, times } from 'lodash'; +import { keyBy } from 'lodash'; import { byLabelText, byRole } from '~sonar-aligned/helpers/testSelector'; import { PARENT_COMPONENT_KEY, RULE_1 } from '../../../api/mocks/data/ids'; -import { mockSnippetsByComponent } from '../../../helpers/mocks/sources'; +import { mockIpynbFile } from '../../../api/mocks/data/sources'; +import { mockSourceLine, mockSourceViewerFile } from '../../../helpers/mocks/sources'; import { mockRawIssue } from '../../../helpers/testMocks'; import { IssueStatus } from '../../../types/issues'; import { @@ -86,11 +87,12 @@ const JUPYTER_ISSUE = { }), snippets: keyBy( [ - mockSnippetsByComponent( - 'jpt.ipynb', - PARENT_COMPONENT_KEY, - times(40, (i) => i + 20), - ), + { + component: mockSourceViewerFile('jpt.ipynb', PARENT_COMPONENT_KEY), + sources: { + 1: mockSourceLine({ line: 1, code: mockIpynbFile }), + }, + }, ], 'component.key', ), @@ -232,6 +234,28 @@ describe('issues source viewer', () => { expect(screen.getByText(/pylab/, { exact: false })).toBeInTheDocument(); }); + it('should not show non-selected issues in code tab of Issues page', async () => { + issuesHandler.setIssueList([ + JUPYTER_ISSUE, + { + ...JUPYTER_ISSUE, + issue: { + ...JUPYTER_ISSUE.issue, + key: 'some-other-issue', + message: 'Another unrelated issue', + }, + }, + ]); + const user = userEvent.setup(); + renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject'); + await waitOnDataLoaded(); + + await user.click(ui.code.get()); + + expect(screen.getAllByRole('button', { name: 'Issue on Jupyter Notebook' })).toHaveLength(2); + expect(screen.queryByText('Another unrelated issue')).not.toBeInTheDocument(); + }); + it('should render issue in jupyter notebook spanning over multiple cells', async () => { issuesHandler.setIssueList([ { diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx index f26177e1e79..da06770ad3f 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesSourceViewer.tsx @@ -17,7 +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 } from 'design-system/lib'; +import { ToggleButton } from 'design-system'; import * as React from 'react'; import { isJupyterNotebookFile } from '~sonar-aligned/helpers/component'; import { translate } from '../../../helpers/l10n'; @@ -58,7 +58,7 @@ export default function IssuesSourceViewer(props: Readonly { + React.useEffect(() => { if ( selectedLocationIndex !== undefined && selectedLocationIndex !== -1 && @@ -80,26 +80,12 @@ export default function IssuesSourceViewer(props: Readonly { - if (selectedLocationIndex === -1) { - refreshScroll(); - } - }, [selectedLocationIndex, refreshScroll]); - const locations = getLocations(openIssue, selectedFlowIndex).map((loc, index) => { loc.index = index; return loc; @@ -137,7 +123,7 @@ export default function IssuesSourceViewer(props: Readonly(null); React.useEffect(() => { - if (!issue.textRange || typeof data !== 'string') { - return; - } + processIssueForJupyterNotebook(data, issue, setRenderedCells); + }, [issue, data]); - let jupyterNotebook: INotebookContent; - try { - jupyterNotebook = JSON.parse(data); - } catch (error) { - setRenderedCells(null); - return; - } + return ( + + {!renderedCells ? ( + + {translate('issue.preview.jupyter_notebook.error')} + + ) : ( + <> + {renderedCells.before.map((cell, index) => ( + + ))} +
+ + } + selected + /> +
+ + + )} +
+ ); +} - const mapper = new JsonIssueMapper(data); - const startOffset = pathToCursorInCell( - mapper.get( - mapper.lineOffsetToCursorPosition(issue.textRange.startLine, issue.textRange.startOffset), - ), - ); - const endOffset = pathToCursorInCell( - mapper.get( - mapper.lineOffsetToCursorPosition(issue.textRange.endLine, issue.textRange.endOffset), - ), - ); - if (!startOffset || !endOffset) { - setRenderedCells(null); - return; - } +/** + * Processes the notebook data and sets the rendered cells based on the issue. + * @param {string} data - The JSON string representing the notebook content. + * @param {Issue} issue - The issue object containing textRange. + * @param {Function} setRenderedCells - Function to update the rendered cells state. + */ +function processIssueForJupyterNotebook( + data: string | null | undefined, + issue: Issue, + setRenderedCells: (cells: { after: ICodeCell; before: ICodeCell[] } | null) => void, +) { + if (typeof data !== 'string') { + return; + } - // When the primary location spans over multiple cells, we show all cells that are part of the range - const cells: ICodeCell[] = jupyterNotebook.cells - .slice(startOffset.cell, endOffset.cell + 1) - .filter((cell) => isCode(cell)); + let jupyterNotebook: INotebookContent; + try { + jupyterNotebook = JSON.parse(data); + } catch (error) { + setRenderedCells(null); + return; + } - // Split the last cell because we want to show the issue message at the end of the primary location - const sourceBefore = cells[cells.length - 1].source.slice(0, endOffset.line + 1); - const sourceAfter = cells[cells.length - 1].source.slice(endOffset.line + 1); - const lastCell = { - ...cells[cells.length - 1], - source: sourceAfter, - }; - cells[cells.length - 1] = { - cell_type: 'code', - source: sourceBefore, - execution_count: 0, - outputs: [], - metadata: {}, - }; + const { startOffset, endOffset } = getOffsetsForIssue(issue, data); - for (let i = 0; i < cells.length; i++) { - const cell = cells[i]; - cell.source = Array.isArray(cell.source) ? cell.source : [cell.source]; + if (!startOffset || !endOffset) { + setRenderedCells(null); + return; + } - // Any cell between the first and last cell should be fully underlined - const start = i === 0 ? startOffset : { line: 0, cursorOffset: 0 }; - const end = - i === cells.length - 1 - ? endOffset - : { - line: cell.source.length - 1, - cursorOffset: cell.source[cell.source.length - 1].length, - }; + // When the primary location spans over multiple cells, we show all cells that are part of the range + const cells: ICodeCell[] = jupyterNotebook.cells + .slice(startOffset.cell, endOffset.cell + 1) + .filter((cell) => isCode(cell)); - cell.source = hljsUnderlinePlugin.tokenize(cell.source, [ - { - start, - end, - }, - ]); - } + // Split the last cell because we want to show the issue message at the end of the primary location + const sourceBefore = cells[cells.length - 1].source.slice(0, endOffset.line + 1); + const sourceAfter = cells[cells.length - 1].source.slice(endOffset.line + 1); + const lastCell = { + ...cells[cells.length - 1], + source: sourceAfter, + }; + cells[cells.length - 1] = { + cell_type: 'code', + source: sourceBefore, + execution_count: 0, + outputs: [], + metadata: {}, + }; - setRenderedCells({ - before: cells, - after: lastCell, - }); - }, [issue, data]); + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]; + cell.source = Array.isArray(cell.source) ? cell.source : [cell.source]; - if (isLoading) { - return ; - } + // Any cell between the first and last cell should be fully underlined + const start = i === 0 ? startOffset : { line: 0, cursorOffset: 0 }; + const end = + i === cells.length - 1 + ? endOffset + : { + line: cell.source.length - 1, + cursorOffset: cell.source[cell.source.length - 1].length, + }; - if (!renderedCells) { - return ( - - {translate('issue.preview.jupyter_notebook.error')} - - ); + cell.source = hljsUnderlinePlugin.tokenize(cell.source, [ + { + start, + end, + }, + ]); } - return ( - <> - {renderedCells.before.map((cell, index) => ( - - ))} -
- - } - selected - /> -
- - - ); + setRenderedCells({ + before: cells, + after: lastCell, + }); } diff --git a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/types.ts b/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/types.ts deleted file mode 100644 index 67e4e0cfd64..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2024 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -export interface JupyterNotebookOutput { - data: { - [key: string]: string | string[]; - 'image/png': string; - 'text/html': string[]; - 'text/plain': string[]; - }; - metadata: { [key: string]: string }; - output_type: string; - text?: string[]; -} - -export interface JupyterNotebookCursorPath { - cell: number; - cursorOffset: number; - line: number; -} diff --git a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/utils.ts b/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/utils.ts deleted file mode 100644 index 076cedd2726..00000000000 --- a/server/sonar-web/src/main/js/apps/issues/jupyter-notebook/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2024 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 { PathToCursor } from '~sonar-aligned/helpers/json-issue-mapper'; - -export function pathToCursorInCell(path: PathToCursor): { - cell: number; - cursorOffset: number; - line: number; -} | null { - const [, cellEntry, , lineEntry, stringEntry] = path; - if ( - cellEntry?.type !== 'array' || - lineEntry?.type !== 'array' || - stringEntry?.type !== 'string' - ) { - return null; - } - return { - cell: cellEntry.index, - line: lineEntry.index, - cursorOffset: stringEntry.index, - }; -} diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/EditionBox.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/EditionBox.tsx index a507e9e1241..e749448dd4e 100644 --- a/server/sonar-web/src/main/js/apps/marketplace/components/EditionBox.tsx +++ b/server/sonar-web/src/main/js/apps/marketplace/components/EditionBox.tsx @@ -20,7 +20,7 @@ import { SubHeading, UnorderedList } from 'design-system'; import * as React from 'react'; -import { Image } from '../../../components/common/Image'; +import { Image } from '~sonar-aligned/components/common/Image'; import { Edition, EditionKey } from '../../../types/editions'; interface Props { diff --git a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanelNoNewCode.tsx b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanelNoNewCode.tsx index ee764a4f15d..c0617ca2c3f 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanelNoNewCode.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanelNoNewCode.tsx @@ -22,11 +22,11 @@ import { Link } from '@sonarsource/echoes-react'; import { Note, getTabPanelId } from 'design-system'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; +import { Image } from '~sonar-aligned/components/common/Image'; import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; import { queryToSearchString } from '~sonar-aligned/helpers/urls'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import DocumentationLink from '../../../components/common/DocumentationLink'; -import { Image } from '../../../components/common/Image'; import { DocLink } from '../../../helpers/doc-links'; import { translate } from '../../../helpers/l10n'; import { CodeScope } from '../../../helpers/urls'; diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx index a60cd6f400a..6b6b1a35411 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx @@ -21,9 +21,9 @@ import { Button, ButtonVariety } from '@sonarsource/echoes-react'; import { FlagMessage, Title } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { isPortfolioLike } from '~sonar-aligned/helpers/component'; import GitHubSynchronisationWarning from '../../../../app/components/GitHubSynchronisationWarning'; -import { Image } from '../../../../components/common/Image'; import { translate, translateWithParameters } from '../../../../helpers/l10n'; import { isDefined } from '../../../../helpers/types'; import { diff --git a/server/sonar-web/src/main/js/apps/projectInformation/badges/ProjectBadges.tsx b/server/sonar-web/src/main/js/apps/projectInformation/badges/ProjectBadges.tsx index 5e9ef2178ee..9a11599bfe9 100644 --- a/server/sonar-web/src/main/js/apps/projectInformation/badges/ProjectBadges.tsx +++ b/server/sonar-web/src/main/js/apps/projectInformation/badges/ProjectBadges.tsx @@ -33,9 +33,9 @@ import { import { isEmpty } from 'lodash'; import * as React from 'react'; import { useState } from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; import { MetricKey } from '~sonar-aligned/types/metrics'; -import { Image } from '../../../components/common/Image'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { localizeMetric } from '../../../helpers/measures'; import { diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenuItem.tsx b/server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenuItem.tsx index ed3d83f2495..cc6bda0c5e9 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenuItem.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/ProjectCreationMenuItem.tsx @@ -20,8 +20,8 @@ import { ItemLink } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { queryToSearchString } from '~sonar-aligned/helpers/urls'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; import { AlmKeys } from '../../../types/alm-settings'; diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/EmptyHotspotsPage.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/EmptyHotspotsPage.tsx index 6cb06038c36..4535bd0c076 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/EmptyHotspotsPage.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/EmptyHotspotsPage.tsx @@ -20,8 +20,8 @@ import { Note } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import DocumentationLink from '../../../components/common/DocumentationLink'; -import { Image } from '../../../components/common/Image'; import { DocLink } from '../../../helpers/doc-links'; import { translate } from '../../../helpers/l10n'; diff --git a/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx b/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx index 047a6e69a06..dd45a7060e3 100644 --- a/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx +++ b/server/sonar-web/src/main/js/apps/sessions/components/Login.tsx @@ -30,8 +30,8 @@ import { } from 'design-system'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; +import { Image } from '~sonar-aligned/components/common/Image'; import { Location } from '~sonar-aligned/types/router'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; import { sanitizeUserInput } from '../../../helpers/sanitize'; import { getReturnUrl } from '../../../helpers/urls'; diff --git a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegrationRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegrationRenderer.tsx index 7ebcb57b0b5..10d65632660 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegrationRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmIntegrationRenderer.tsx @@ -22,7 +22,7 @@ import { Link } from '@sonarsource/echoes-react'; import { FlagMessage, SubTitle, ToggleButton } from 'design-system'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { Image } from '../../../../components/common/Image'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate } from '../../../../helpers/l10n'; import { isDefined } from '../../../../helpers/types'; import { useGetValuesQuery } from '../../../../queries/settings'; diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx index 4fcd94d70d4..ddb88e3a2d4 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx @@ -24,11 +24,11 @@ import { FlagMessage, SubTitle, ToggleButton, getTabId, getTabPanelId } from 'de import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { useSearchParams } from 'react-router-dom'; +import { Image } from '~sonar-aligned/components/common/Image'; import { searchParamsToQuery } from '~sonar-aligned/helpers/router'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from '../../../../app/components/available-features/withAvailableFeatures'; -import { Image } from '../../../../components/common/Image'; import { translate } from '../../../../helpers/l10n'; import { AlmKeys } from '../../../../types/alm-settings'; import { Feature } from '../../../../types/features'; diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx index 8d96b903376..eb52a7cac45 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx @@ -20,8 +20,8 @@ import { Badge, Note, getTextColor } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { colors } from '../../../app/theme'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; import { isDefined } from '../../../helpers/types'; import { IdentityProvider, Provider } from '../../../types/types'; diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx index 4cece1cc978..4bc81fbc199 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx @@ -18,9 +18,10 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ICell, isCode, isMarkdown } from '@jupyterlab/nbformat'; +import { ICell, INotebookContent, isCode, isMarkdown } from '@jupyterlab/nbformat'; import { Spinner } from '@sonarsource/echoes-react'; import { + Card, FlagMessage, hljsIssueIndicatorPlugin, hljsUnderlinePlugin, @@ -36,10 +37,9 @@ import { JupyterMarkdownCell, } from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer'; import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like'; -import { JsonIssueMapper } from '~sonar-aligned/helpers/json-issue-mapper'; +import { getOffsetsForIssue } from '~sonar-aligned/helpers/json-issue-mapper'; import { getComponentIssuesUrl } from '~sonar-aligned/helpers/urls'; import { ComponentContext } from '../../app/components/componentContext/ComponentContext'; -import { pathToCursorInCell } from '../../apps/issues/jupyter-notebook/utils'; import { parseQuery, serializeQuery } from '../../apps/issues/utils'; import { translate } from '../../helpers/l10n'; import { getIssuesUrl } from '../../helpers/urls'; @@ -70,7 +70,6 @@ type IssueIndicatorsProps = { issuesByCell: IssuesByCell; jupyterRef: React.RefObject; }; - type IssueMapper = { issueUrl: To; key: string; @@ -95,7 +94,7 @@ export default function SourceViewerPreview(props: Readonly) { return null; } try { - return JSON.parse(data) as { cells: ICell[] }; + return JSON.parse(data) as INotebookContent; } catch (error) { return null; } @@ -111,64 +110,8 @@ export default function SourceViewerPreview(props: Readonly) { }, [component, branchLike]); useEffect(() => { - const newIssuesByCell: IssuesByCell = {}; - - if (!jupyterNotebook) { - return; - } - - issues.forEach((issue) => { - if (!issue.textRange) { - return; - } - - if (typeof data !== 'string') { - return; - } - - const mapper = new JsonIssueMapper(data); - const start = mapper.lineOffsetToCursorPosition( - issue.textRange.startLine, - issue.textRange.startOffset, - ); - const end = mapper.lineOffsetToCursorPosition( - issue.textRange.endLine, - issue.textRange.endOffset, - ); - - const startOffset = pathToCursorInCell(mapper.get(start)); - const endOffset = pathToCursorInCell(mapper.get(end)); - - if (!startOffset || !endOffset) { - return; - } - - if (startOffset.cell !== endOffset.cell) { - return; - } - - const { cell } = startOffset; - - if (!newIssuesByCell[cell]) { - newIssuesByCell[cell] = {}; - } - - if (!newIssuesByCell[cell][startOffset.line]) { - newIssuesByCell[cell][startOffset.line] = [{ issue, start: startOffset, end: endOffset }]; - } - - const existingIssues = newIssuesByCell[cell][startOffset.line]; - const issueExists = existingIssues.some( - ({ issue: existingIssue }) => existingIssue.key === issue.key, - ); - - if (!issueExists) { - newIssuesByCell[cell][startOffset.line].push({ issue, start: startOffset, end: endOffset }); - } - }); - - setIssuesByCell(newIssuesByCell); - }, [issues, data, jupyterNotebook]); + processIssuesByCell(issues, data, setIssuesByCell); + }, [issues, data]); if (isLoading) { return ; @@ -191,8 +134,8 @@ export default function SourceViewerPreview(props: Readonly) { } return ( - <> - + ) { jupyterRef={jupyterNotebookRef} /> )} - + ); } @@ -221,7 +164,7 @@ function mapIssuesToIssueKeys(issuesByLine: IssuesByLine): IssueKeysByLine { }, {} as IssueKeysByLine); } -const RenderJupyterNotebook = forwardRef( +const JupyterNotebookSourceViewer = forwardRef( ({ cells, issuesByCell }, ref) => { const buildCellsBlocks = useMemo(() => { return cells.map((cell: ICell, index: number) => { @@ -268,7 +211,7 @@ const RenderJupyterNotebook = forwardRef( }, ); -RenderJupyterNotebook.displayName = 'RenderJupyterNotebook'; +JupyterNotebookSourceViewer.displayName = 'JupyterNotebookSourceViewer'; function IssueIndicators({ issuesByCell, @@ -308,6 +251,11 @@ function IssueIndicators({ )); } +/* +Creates a react portal bound to an element id `issue-key-${key}` that represents the first issue +present on a virtual line of code. The element id is generated from executing the +HljsIssueIndicatorPlugin.addIssuesToLines function on the source code of a Jupyter notebook cell. +*/ function PortalLineIssuesIndicator(props: { issueMapper: IssueMapper; jupyterRef: React.RefObject; @@ -315,7 +263,9 @@ function PortalLineIssuesIndicator(props: { const { jupyterRef, issueMapper } = props; const router = useRouter(); - const [mutationCount, setMutationCount] = useState(0); + // We use this state to force-re-render the component when the jupyterRef changes + // eslint-disable-next-line react/hook-use-state + const [, setMutationCount] = useState(0); useEffect(() => { if (!jupyterRef.current) { @@ -327,14 +277,10 @@ function PortalLineIssuesIndicator(props: { const { key, lineIndex, onlyIssues, issueUrl } = issueMapper; const element = document.getElementById(`issue-key-${key}`); - // we don't have the jupyterRef yet - if (mutationCount === 0) { - return null; - } - if (!element) { return null; } + return createPortal( { + if (typeof data !== 'string') { + return; + } + + const { startOffset, endOffset } = getOffsetsForIssue(issue, data); + + // failed to parse the issue offsets, skip the issue + if (!startOffset || !endOffset) { + return; + } + + const { cell } = startOffset; + + if (!newIssuesByCell[cell]) { + newIssuesByCell[cell] = {}; + } + + if (!newIssuesByCell[cell][startOffset.line]) { + newIssuesByCell[cell][startOffset.line] = [{ issue, start: startOffset, end: endOffset }]; + } + + const existingIssues = newIssuesByCell[cell][startOffset.line]; + const issueExists = existingIssues.some( + ({ issue: existingIssue }) => existingIssue.key === issue.key, + ); + + if (!issueExists) { + newIssuesByCell[cell][startOffset.line].push({ issue, start: startOffset, end: endOffset }); + } + }); + + setIssuesByCell(newIssuesByCell); +} diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx index 2707b60a7c1..5bffded6ae8 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx @@ -20,10 +20,10 @@ import { DropdownMenu } from '@sonarsource/echoes-react'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { DocLink } from '../../helpers/doc-links'; import { translate } from '../../helpers/l10n'; import { SuggestionLink } from '../../types/types'; -import { Image } from '../common/Image'; import { DocItemLink } from './DocItemLink'; import { SuggestionsContext } from './SuggestionsContext'; diff --git a/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx index 2ced9099bdb..19b3369f9fe 100644 --- a/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/GroupHolder.tsx @@ -20,12 +20,12 @@ import { Badge, ContentCell, TableRowInteractive, UserGroupIcon } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate } from '../../helpers/l10n'; import { isPermissionDefinitionGroup } from '../../helpers/permissions'; import { isDefined } from '../../helpers/types'; import { Permissions } from '../../types/permissions'; import { PermissionDefinitions, PermissionGroup } from '../../types/types'; -import { Image } from '../common/Image'; import PermissionCell from './PermissionCell'; import usePermissionChange from './usePermissionChange'; diff --git a/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx index 34d2dd9eb03..b0088590290 100644 --- a/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx +++ b/server/sonar-web/src/main/js/components/permissions/UserHolder.tsx @@ -20,11 +20,11 @@ import { Avatar, ContentCell, Note, TableRowInteractive } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate } from '../../helpers/l10n'; import { isPermissionDefinitionGroup } from '../../helpers/permissions'; import { isDefined } from '../../helpers/types'; import { PermissionDefinitions, PermissionUser } from '../../types/types'; -import { Image } from '../common/Image'; import PermissionCell from './PermissionCell'; import usePermissionChange from './usePermissionChange'; diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx index e5c692ba5cc..8209418f3cd 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx @@ -29,6 +29,7 @@ import { Title, } from 'design-system'; import * as React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { isMainBranch } from '~sonar-aligned/helpers/branch-like'; import { AnalysisStatus } from '../../apps/overview/components/AnalysisStatus'; import { translate } from '../../helpers/l10n'; @@ -38,7 +39,6 @@ import { AlmKeys, AlmSettingsInstance, ProjectAlmBindingResponse } from '../../t import { MainBranch } from '../../types/branch-like'; import { Component } from '../../types/types'; import { LoggedInUser } from '../../types/users'; -import { Image } from '../common/Image'; import AzurePipelinesTutorial from './azure-pipelines/AzurePipelinesTutorial'; import BitbucketPipelinesTutorial from './bitbucket-pipelines/BitbucketPipelinesTutorial'; import GitHubActionTutorial from './github-action/GitHubActionTutorial'; diff --git a/server/sonar-web/src/main/js/components/tutorials/components/GithubCFamilyExampleRepositories.tsx b/server/sonar-web/src/main/js/components/tutorials/components/GithubCFamilyExampleRepositories.tsx index b7c07a68335..d06ad32d487 100644 --- a/server/sonar-web/src/main/js/components/tutorials/components/GithubCFamilyExampleRepositories.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/components/GithubCFamilyExampleRepositories.tsx @@ -22,8 +22,8 @@ import { LinkStandalone } from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { Card, LightLabel } from 'design-system'; import React from 'react'; +import { Image } from '~sonar-aligned/components/common/Image'; import { translate } from '../../../helpers/l10n'; -import { Image } from '../../common/Image'; import { OSs, TutorialModes } from '../types'; export interface GithubCFamilyExampleRepositoriesProps { diff --git a/server/sonar-web/src/main/js/queries/sources.ts b/server/sonar-web/src/main/js/queries/sources.ts index 2e74ff6510f..aacb3a79f53 100644 --- a/server/sonar-web/src/main/js/queries/sources.ts +++ b/server/sonar-web/src/main/js/queries/sources.ts @@ -17,26 +17,22 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { useQuery } from '@tanstack/react-query'; +import { queryOptions } from '@tanstack/react-query'; import { getRawSource } from '../api/sources'; -import { RequestData } from '../helpers/request'; import { BranchParameters } from '../sonar-aligned/types/branch-like'; +import { createQueryHook } from './common'; -function getIssuesQueryKey(data: RequestData) { - return ['issues', JSON.stringify(data ?? '')]; -} - -function fetchRawSources({ queryKey: [, query] }: { queryKey: string[] }) { - if (typeof query !== 'string') { - return null; - } +// This will prevent refresh when navigating from page to page. +const SOURCES_STALE_TIME = 60_000; - return getRawSource(JSON.parse(query) as BranchParameters & { key: string }); +function getSourcesQueryKey(key: string, branchParameters: BranchParameters) { + return ['sources', 'details', key, branchParameters]; } -export function useRawSourceQuery(data: BranchParameters & { key: string }) { - return useQuery({ - queryKey: getIssuesQueryKey(data), - queryFn: fetchRawSources, +export const useRawSourceQuery = createQueryHook((data: BranchParameters & { key: string }) => { + return queryOptions({ + queryKey: getSourcesQueryKey(data.key, data), + queryFn: () => getRawSource(data), + staleTime: SOURCES_STALE_TIME, }); -} +}); diff --git a/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx b/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx index 180566a7ad9..1d5dc53e614 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx +++ b/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx @@ -19,22 +19,19 @@ */ import { - ICell, IMarkdownCell, IOutput, - isCode, isDisplayData, isExecuteResult, - isMarkdown, isStream, } from '@jupyterlab/nbformat'; import classNames from 'classnames'; -import { CodeSnippet } from 'design-system/lib'; +import { CodeSnippet } from 'design-system'; import { isArray } from 'lodash'; import React from 'react'; import Markdown from 'react-markdown'; -import { Image } from '../../../components/common/Image'; import { translate } from '../../../helpers/l10n'; +import { Image } from '../common/Image'; export function JupyterMarkdownCell({ cell }: Readonly<{ cell: IMarkdownCell }>) { const markdown = isArray(cell.source) ? cell.source.join('') : cell.source; @@ -67,7 +64,7 @@ function CellOutput({ output }: Readonly<{ output: IOutput }>) { } return null; }); - return components; + return <>{components}; } else if (isStream(output)) { const text = isArray(output.text) ? output.text.join('') : output.text; return
{text}
; @@ -93,16 +90,3 @@ export function JupyterCodeCell( ); } - -export function JupyterCell({ cell }: Readonly<{ cell: ICell }>) { - const source = Array.isArray(cell.source) ? cell.source : [cell.source]; - if (isCode(cell)) { - return ; - } - - if (isMarkdown(cell)) { - return ; - } - - return null; -} diff --git a/server/sonar-web/src/main/js/components/common/Image.tsx b/server/sonar-web/src/main/js/sonar-aligned/components/common/Image.tsx similarity index 96% rename from server/sonar-web/src/main/js/components/common/Image.tsx rename to server/sonar-web/src/main/js/sonar-aligned/components/common/Image.tsx index e15f8df3fcc..83607a1caa2 100644 --- a/server/sonar-web/src/main/js/components/common/Image.tsx +++ b/server/sonar-web/src/main/js/sonar-aligned/components/common/Image.tsx @@ -19,7 +19,7 @@ */ import * as React from 'react'; -import { getBaseUrl } from '../../helpers/system'; +import { getBaseUrl } from '../../../helpers/system'; export function Image(props: Readonly) { const { alt, src: source, ...rest } = props; diff --git a/server/sonar-web/src/main/js/sonar-aligned/components/common/__tests__/Image-test.tsx b/server/sonar-web/src/main/js/sonar-aligned/components/common/__tests__/Image-test.tsx new file mode 100644 index 00000000000..2b26293a738 --- /dev/null +++ b/server/sonar-web/src/main/js/sonar-aligned/components/common/__tests__/Image-test.tsx @@ -0,0 +1,54 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 React from 'react'; +import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { Image } from '../Image'; + +describe('should render correctly', () => { + it('with a src', () => { + setupWithProps({ + src: 'foo.png', + }); + + expect(screen.getByRole('img').outerHTML).toEqual(''); + }); + + it('with a src and alt', () => { + setupWithProps({ + src: 'foo.png', + alt: 'bar', + }); + + expect(screen.getByRole('img').outerHTML).toEqual('bar'); + }); + + it('should strip beginning slashes', () => { + setupWithProps({ + src: '/foo.png', + }); + + expect(screen.getByRole('img').outerHTML).toEqual(''); + }); +}); + +function setupWithProps(props: Partial> = {}) { + return renderComponent(); +} diff --git a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/component-test.ts b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/component-test.ts index cbc68999f94..e47c73eb8be 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/component-test.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/component-test.ts @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { ComponentQualifier } from '~sonar-aligned/types/component'; -import { isPortfolioLike } from '../component'; +import { isJupyterNotebookFile, isPortfolioLike } from '../component'; it.each([[isPortfolioLike]])( '%p should work properly', @@ -30,3 +30,11 @@ it.each([[isPortfolioLike]])( expect(results).toMatchSnapshot(); }, ); + +it.each([ + ['foo.ipynb', true], + ['foo.py', false], + ['foo.ipynb.py', false], +])('%s is a Jupyter notebook file: %p', (componentKey, expected) => { + expect(isJupyterNotebookFile(componentKey)).toBe(expected); +}); diff --git a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts index 83cbddda97f..1566fb5ad76 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/helpers/__tests__/json-issue-mapper-test.ts @@ -38,7 +38,7 @@ const fixtures = loadFixtures(); describe('JsonIssueMapper', () => { it('should return cursor position in file from line offset', () => { const parser = new JsonIssueMapper(fixtures['00-object-simple.json']); - expect(parser.lineOffsetToCursorPosition(12, 31)).toEqual(224); + expect(parser.lineOffsetToCursorPosition(12, 31)).toEqual(225); }); describe('should not fail on invalid json', () => { @@ -56,23 +56,29 @@ describe('JsonIssueMapper', () => { describe('should return correct path in strings', () => { it('gets cursor path in a string value', () => { const parser = new JsonIssueMapper(fixtures['00-object-simple.json']); - const cursor = 20; - expect(parser.get(cursor)).toEqual([ - { type: 'object', key: 'first-key' }, - { type: 'string', index: 2 }, - ]); + for (const cursor of [20, 21]) { + expect(parser.get(cursor)).toEqual([ + { type: 'object', key: 'first-key' }, + { type: 'string', index: 2 }, + ]); + } }); it('ignores false-flag characters in strings', () => { const parser = new JsonIssueMapper(fixtures['01-object-false-flags.json']); const cursor = 111; - expect(parser.get(cursor)).toEqual([ + const path = parser.get(cursor); + expect(path).toEqual([ { type: 'object', key: '\\"{}}[]]].:-]\\\\\\\\' }, { type: 'array', index: 0 }, { type: 'array', index: 0 }, { type: 'object', key: '\\"{}}[]]].:-]\\\\\\\\' }, - { type: 'string', index: 20 }, + { type: 'string', index: 17 }, ]); + + const object = JSON.parse(fixtures['01-object-false-flags.json']); + const value = object['"{}}[]]].:-]\\\\'][0][0]['"{}}[]]].:-]\\\\']; + expect(value[17]).toEqual('u'); }); it('detects cursor in empty strings', () => { @@ -107,7 +113,7 @@ describe('JsonIssueMapper', () => { { type: 'array', index: 1 }, { type: 'object', key: 'data' }, { type: 'object', key: 'image/png' }, - { type: 'string', index: 24 }, + { type: 'string', index: 23 }, ]); }); @@ -119,7 +125,7 @@ describe('JsonIssueMapper', () => { { type: 'array', index: 1 }, { type: 'object', key: 'source' }, { type: 'array', index: 8 }, - { type: 'string', index: 15 }, + { type: 'string', index: 14 }, ]); }); }); diff --git a/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts b/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts index adb16a3756a..0dc685684a3 100644 --- a/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.ts +++ b/server/sonar-web/src/main/js/sonar-aligned/helpers/json-issue-mapper.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 { Issue } from '../../types/types'; + export type PathEntry = | { key: string; @@ -40,8 +42,13 @@ type ParseStepResult = { }; export class JsonIssueMapper { + /** + * Stop-character for literals (first chars of true, false, null, undefined) + */ static readonly TOKEN_LITERAL = 'tfnu'.split(''); - static readonly TOKEN_SCOPE_ENTRY = '{["0123456789tfnu'.split(''); + + static readonly TOKEN_SCOPE_ENTRY = `{["0123456789${JsonIssueMapper.TOKEN_LITERAL}`.split(''); + static readonly TOKEN_SCOPE_EXIT = ',}]'.split(''); private readonly code: string; @@ -67,7 +74,7 @@ export class JsonIssueMapper { this.splitCode = this.code.split('\n'); } const charsBeforeStartLine = this.splitCode.slice(0, startLine - 1).join('\n').length; - return charsBeforeStartLine + startOffset; + return charsBeforeStartLine + startOffset + (startLine > 1 ? 1 : 0); } get(cursorPosition: number): PathToCursor { @@ -242,11 +249,11 @@ export class JsonIssueMapper { return 0; } - let count = 0; + let count = -1; let i = 0; while (i < index) { - // Ignore escaped quotes - if (this.code[firstQuoteIndex + i] === '\\' && this.code[firstQuoteIndex + i + 1] === '"') { + // Ignore escaped characters + if (this.code[firstQuoteIndex + 1 + i] === '\\') { i += 2; } else { i += 1; @@ -336,3 +343,44 @@ export class JsonIssueMapper { return startIndex <= this.cursorPosition && this.cursorPosition <= endIndex; } } + +export function pathToCursorInCell(path: PathToCursor): { + cell: number; + cursorOffset: number; + line: number; +} | null { + const [, cellEntry, , lineEntry, stringEntry] = path; + if ( + cellEntry?.type !== 'array' || + lineEntry?.type !== 'array' || + stringEntry?.type !== 'string' + ) { + return null; + } + return { + cell: cellEntry.index, + line: lineEntry.index, + cursorOffset: stringEntry.index, + }; +} + +export function getOffsetsForIssue(issue: Issue, data: string) { + if (!issue.textRange) { + return { startOffset: null, endOffset: null }; + } + + const mapper = new JsonIssueMapper(data); + + const startOffset = pathToCursorInCell( + mapper.get( + mapper.lineOffsetToCursorPosition(issue.textRange.startLine, issue.textRange.startOffset), + ), + ); + const endOffset = pathToCursorInCell( + mapper.get( + mapper.lineOffsetToCursorPosition(issue.textRange.endLine, issue.textRange.endOffset), + ), + ); + + return { startOffset, endOffset }; +} -- 2.39.5