From 176045866b4d946f1ff764719d29024de10c261c Mon Sep 17 00:00:00 2001 From: Lucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com> Date: Mon, 29 Jul 2024 15:47:33 +0300 Subject: [PATCH] SONAR-22498 CodeSnippet supports issue indicator for jupyter preview (#11414) --- .../src/components/CodeSyntaxHighlighter.tsx | 3 +- .../hljs/HljsIssueIndicatorPlugin.ts | 135 ++++++++++ .../HljsIssueIndicatorPlugin-test.ts | 126 +++++++++ .../src/sonar-aligned/hljs/index.ts | 2 + .../src/main/js/api/mocks/data/sources.ts | 5 + .../main/js/apps/code/__tests__/Code-it.ts | 75 +++++- .../SourceViewer/SourceViewerPreview.tsx | 253 +++++++++++++++++- .../components/LineIssuesIndicator.tsx | 5 +- .../SourceViewer/JupyterNotebookViewer.tsx | 17 +- .../resources/org/sonar/l10n/core.properties | 1 + 10 files changed, 597 insertions(+), 25 deletions(-) create mode 100644 server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts create mode 100644 server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsIssueIndicatorPlugin-test.ts diff --git a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx index 502cc4a9eae..1a35a36d10a 100644 --- a/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx +++ b/server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx @@ -25,7 +25,7 @@ import cobol from 'highlightjs-cobol'; import abap from 'highlightjs-sap-abap'; import tw from 'twin.macro'; import { themeColor, themeContrast } from '../helpers/theme'; -import { hljsUnderlinePlugin } from '../sonar-aligned/hljs/HljsUnderlinePlugin'; +import { hljsIssueIndicatorPlugin, hljsUnderlinePlugin } from '../sonar-aligned'; hljs.registerLanguage('abap', abap); hljs.registerLanguage('apex', apex); @@ -39,6 +39,7 @@ hljs.registerAliases('secrets', { languageName: 'markdown' }); hljs.registerAliases('web', { languageName: 'xml' }); hljs.registerAliases(['cloudformation', 'kubernetes'], { languageName: 'yaml' }); +hljs.addPlugin(hljsIssueIndicatorPlugin); hljs.addPlugin(hljsUnderlinePlugin); interface Props { 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 new file mode 100644 index 00000000000..08d32b3468e --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts @@ -0,0 +1,135 @@ +/* + * 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 { BeforeHighlightContext, HighlightResult } from 'highlight.js'; + +const BREAK_LINE_REGEXP = /\n/g; + +export class HljsIssueIndicatorPlugin { + static readonly LINE_WRAPPER_STYLE = [ + 'display: inline-grid', + 'grid-template-rows: auto', + 'grid-template-columns: 26px 1fr', + 'align-items: center', + ].join(';'); + + private issueKeys: { [key: string]: string[] }; + static readonly LINE_WRAPPER_OPEN_TAG = `
`; + static readonly LINE_WRAPPER_CLOSE_TAG = `
`; + static readonly EMPTY_INDICATOR_COLUMN = `
`; + public lineIssueIndicatorElement(issueKey: string) { + return `
`; + } + + constructor() { + this.issueKeys = {}; + } + + 'before:highlight'(data: BeforeHighlightContext) { + data.code = this.extractIssue(data.code); + } + + 'after:highlight'(data: HighlightResult) { + if (Object.keys(this.issueKeys).length > 0) { + data.value = this.addIssueIndicator(data.value); + } + // reset issueKeys for next CodeSnippet + this.issueKeys = {}; + } + + addIssuesToLines = (sourceLines: string[], issues: { [line: number]: string[] }) => { + return sourceLines.map((line, lineIndex) => { + const issuesByLine = issues[lineIndex]; + if (!issues || !issuesByLine) { + return line; + } + + return `[ISSUE_KEYS:${issuesByLine.join(',')}]${line}`; + }); + }; + + private getLines(text: string) { + if (text.length === 0) { + return []; + } + return text.split(BREAK_LINE_REGEXP); + } + + private extractIssue(inputHtml: string) { + const lines = this.getLines(inputHtml); + const issueKeysPattern = /\[ISSUE_KEYS:([^\]]+)\](.+)/; + const removeIssueKeysPattern = /\[ISSUE_KEYS:[^\]]+\](.+)/; + + const wrappedLines = lines.map((line, index) => { + const match = issueKeysPattern.exec(line); + + if (match) { + const issueKeys = match[1].split(','); + if (!this.issueKeys[index]) { + this.issueKeys[index] = issueKeys; + } else { + this.issueKeys[index].push(...issueKeys); + } + } + + const result = removeIssueKeysPattern.exec(line); + + return result ? result[1] : line; + }); + + return wrappedLines.join('\n'); + } + + private addIssueIndicator(inputHtml: string) { + const lines = this.getLines(inputHtml); + + const wrappedLines = lines.map((line, index) => { + const issueKeys = this.issueKeys[index]; + + if (issueKeys) { + // the react portal looks for the first issue key + const referenceIssueKey = issueKeys[0]; + return [ + HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG, + this.lineIssueIndicatorElement(referenceIssueKey), + '
', + line, + '
', + HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG, + ].join(''); + } + + // Keep the correct structure when at least one line has issues + return [ + HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG, + HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN, + '
', + line, + '
', + HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG, + ].join(''); + }); + + return wrappedLines.join('\n'); + } +} + +const hljsIssueIndicatorPlugin = new HljsIssueIndicatorPlugin(); +export { hljsIssueIndicatorPlugin }; diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsIssueIndicatorPlugin-test.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsIssueIndicatorPlugin-test.ts new file mode 100644 index 00000000000..2760ee2c65b --- /dev/null +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsIssueIndicatorPlugin-test.ts @@ -0,0 +1,126 @@ +/* + * 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 { BeforeHighlightContext, HighlightResult } from 'highlight.js'; +import { hljsIssueIndicatorPlugin, HljsIssueIndicatorPlugin } from '../HljsIssueIndicatorPlugin'; + +describe('HljsIssueIndicatorPlugin', () => { + it('should prepend to the line the issues that were found', () => { + expect( + hljsIssueIndicatorPlugin.addIssuesToLines(['line1', 'line2', 'line3', `line4`, 'line5'], { + 1: ['123abd', '234asd'], + }), + ).toEqual(['line1', '[ISSUE_KEYS:123abd,234asd]line2', 'line3', `line4`, 'line5']); + + expect( + hljsIssueIndicatorPlugin.addIssuesToLines(['line1', 'line2', 'line3', `line4`, 'line5'], { + 1: ['123abd'], + }), + ).toEqual(['line1', '[ISSUE_KEYS:123abd]line2', 'line3', `line4`, 'line5']); + }); + describe('when tokens exist in the code snippet', () => { + it('should indicate an issue on a line', () => { + const inputHtml = { + code: hljsIssueIndicatorPlugin + .addIssuesToLines(['line1', 'line2', 'line3', `line4`, 'line5'], { 1: ['123abd'] }) + .join('\n'), + } as BeforeHighlightContext; + const result = { + value: ['line1', `line2`, 'line3', `line4`, 'line5'].join('\n'), + } as HighlightResult; + + //find issue keys + hljsIssueIndicatorPlugin['before:highlight'](inputHtml); + //add the issue indicator html + hljsIssueIndicatorPlugin['after:highlight'](result); + + expect(result.value).toEqual( + [ + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line1
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}
line2
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line3
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line4
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line5
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + ].join('\n'), + ); + }); + + it('should support multiple issues found on one line', () => { + const inputHtml = { + code: hljsIssueIndicatorPlugin + .addIssuesToLines(['line1', 'line2 issue2', 'line3', `line4`, 'line5'], { + 1: ['123abd', '234asd'], + }) + .join('\n'), + } as BeforeHighlightContext; + const result = { + value: ['line1', `line2 issue2`, 'line3', `line4`, 'line5'].join('\n'), + } as HighlightResult; + + //find issue keys + hljsIssueIndicatorPlugin['before:highlight'](inputHtml); + //add the issue indicator html + hljsIssueIndicatorPlugin['after:highlight'](result); + + expect(result.value).toEqual( + [ + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line1
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}
line2 issue2
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line3
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line4
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}
line5
${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`, + ].join('\n'), + ); + }); + + it('should not render anything if no source code is passed', () => { + const inputHtml = { + code: '', + } as BeforeHighlightContext; + const result = { + value: '', + } as HighlightResult; + + //find issue keys + hljsIssueIndicatorPlugin['before:highlight'](inputHtml); + //add the issue indicator html + hljsIssueIndicatorPlugin['after:highlight'](result); + + expect(result.value).toEqual(''); + }); + }); + + describe('when no tokens exist in the code snippet', () => { + it('should not change the source', () => { + const inputHtml = { + code: ['line1', `line2`, 'line3', `line4`, 'line5'].join('\n'), + } as BeforeHighlightContext; + const result = { + value: ['line1', `line2`, 'line3', `line4`, 'line5'].join('\n'), + } as HighlightResult; + + //find issue keys + hljsIssueIndicatorPlugin['before:highlight'](inputHtml); + //add the issue indicator html + hljsIssueIndicatorPlugin['after:highlight'](result); + + expect(result.value).toEqual(['line1', 'line2', 'line3', 'line4', 'line5'].join('\n')); + }); + }); +}); diff --git a/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts index b7394666777..0816564955e 100644 --- a/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts +++ b/server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts @@ -17,4 +17,6 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +export { hljsIssueIndicatorPlugin } from './HljsIssueIndicatorPlugin'; export { hljsUnderlinePlugin } from './HljsUnderlinePlugin'; diff --git a/server/sonar-web/src/main/js/api/mocks/data/sources.ts b/server/sonar-web/src/main/js/api/mocks/data/sources.ts index d77794ecf00..7b1b1d28486 100644 --- a/server/sonar-web/src/main/js/api/mocks/data/sources.ts +++ b/server/sonar-web/src/main/js/api/mocks/data/sources.ts @@ -83,5 +83,10 @@ export const mockIpynbFile = JSON.stringify({ ], source: ['import pylab as pl\n', '%matplotlib inline\n', 'pl.plot(x, y)'], }, + { + cell_type: 'markdown', + metadata: {}, + source: '# markdown as a string', + }, ], }); 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 156bbafe15b..0bc8b5494ab 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 @@ -21,19 +21,31 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { keyBy, omit, times } from 'lodash'; -import { QuerySelector, byLabelText, byRole, byText } from '~sonar-aligned/helpers/testSelector'; +import { + QuerySelector, + byLabelText, + byRole, + byTestId, + byText, +} from '~sonar-aligned/helpers/testSelector'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import { MetricKey } from '~sonar-aligned/types/metrics'; import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; +import { PARENT_COMPONENT_KEY, RULE_1 } from '../../../api/mocks/data/ids'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import SourcesServiceMock from '../../../api/mocks/SourcesServiceMock'; import { CCT_SOFTWARE_QUALITY_METRICS } from '../../../helpers/constants'; import { isDiffMetric } from '../../../helpers/measures'; import { mockComponent } from '../../../helpers/mocks/component'; -import { mockSourceLine, mockSourceViewerFile } from '../../../helpers/mocks/sources'; -import { mockMeasure } from '../../../helpers/testMocks'; +import { + mockSnippetsByComponent, + mockSourceLine, + mockSourceViewerFile, +} from '../../../helpers/mocks/sources'; +import { mockMeasure, mockRawIssue } from '../../../helpers/testMocks'; import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; +import { IssueStatus } from '../../../types/issues'; import { Component } from '../../../types/types'; import routes from '../routes'; @@ -59,6 +71,40 @@ const componentsHandler = new ComponentsServiceMock(); const sourcesHandler = new SourcesServiceMock(); const issuesHandler = new IssuesServiceMock(); +const JUPYTER_ISSUE = { + issue: mockRawIssue(false, { + key: 'some-issue', + component: `${PARENT_COMPONENT_KEY}:jpt.ipynb`, + message: 'Issue on Jupyter Notebook', + rule: RULE_1, + textRange: { + startLine: 1, + endLine: 1, + startOffset: 1148, + endOffset: 1159, + }, + ruleDescriptionContextKey: 'spring', + ruleStatus: 'DEPRECATED', + quickFixAvailable: true, + tags: ['unused'], + project: 'org.sonarsource.javascript:javascript', + assignee: 'email1@sonarsource.com', + author: 'email3@sonarsource.com', + issueStatus: IssueStatus.Confirmed, + prioritizedRule: true, + }), + snippets: keyBy( + [ + mockSnippetsByComponent( + 'jpt.ipynb', + PARENT_COMPONENT_KEY, + times(40, (i) => i + 20), + ), + ], + 'component.key', + ), +}; + beforeAll(() => { Object.defineProperty(window, 'scrollTo', { writable: true, @@ -439,6 +485,7 @@ it('should correctly show new VS overall measures for Portfolios', async () => { }); it('should render correctly for ipynb files', async () => { + issuesHandler.setIssueList([JUPYTER_ISSUE]); const component = mockComponent({ ...componentsHandler.findComponentTree('foo')?.component, qualifier: ComponentQualifier.Project, @@ -475,6 +522,10 @@ it('should render correctly for ipynb files', async () => { await ui.clickOnChildComponent(/ipynb$/); + await ui.clickToggleCode(); + expect(ui.sourceCode.get()).toBeInTheDocument(); + + await ui.clickTogglePreview(); expect(ui.previewToggle.get()).toBeInTheDocument(); expect(ui.previewToggleOption().get()).toBeChecked(); expect(ui.previewMarkdown.get()).toBeInTheDocument(); @@ -482,10 +533,13 @@ it('should render correctly for ipynb files', async () => { expect(ui.previewOutputImage.get()).toBeInTheDocument(); expect(ui.previewOutputText.get()).toBeInTheDocument(); expect(ui.previewOutputStream.get()).toBeInTheDocument(); + expect(ui.previewIssueUnderline.get()).toBeInTheDocument(); - await ui.clickToggleCode(); + expect(await ui.previewIssueIndicator.find()).toBeInTheDocument(); - expect(ui.sourceCode.get()).toBeInTheDocument(); + await ui.clickIssueIndicator(); + + expect(ui.issuesViewPage.get()).toBeInTheDocument(); }); function getPageObject(user: UserEvent) { @@ -504,6 +558,11 @@ function getPageObject(user: UserEvent) { noResultsTxt: byText('no_results'), sourceCode: byText('function Test() {}'), previewCode: byText('numpy', { exact: false }), + previewIssueUnderline: byTestId('hljs-sonar-underline'), + previewIssueIndicator: byRole('button', { + name: 'source_viewer.issues_on_line.multiple_issues_same_category.true.1.issue.clean_code_attribute_category.responsible', + }), + issuesViewPage: byText('/project/issues?open=some-issue&id=foo'), previewMarkdown: byText('Learning a cosine with keras'), previewOutputImage: byRole('img', { name: 'source_viewer.jupyter.output.image' }), previewOutputText: byText('[]'), @@ -547,6 +606,12 @@ function getPageObject(user: UserEvent) { async clickToggleCode() { await user.click(ui.previewToggleOption('Code').get()); }, + async clickTogglePreview() { + await user.click(ui.previewToggleOption('Preview').get()); + }, + async clickIssueIndicator() { + await user.click(ui.previewIssueIndicator.get()); + }, async appLoaded(name = 'Foo') { await waitFor(() => { expect(ui.componentName(name).get()).toBeInTheDocument(); 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 1a1fa036e18..e56b792194e 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx @@ -18,28 +18,142 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ICell } from '@jupyterlab/nbformat'; +import { ICell, isCode, isMarkdown } from '@jupyterlab/nbformat'; import { Spinner } from '@sonarsource/echoes-react'; -import { FlagMessage } from 'design-system/lib'; -import React from 'react'; -import { JupyterCell } from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer'; +import { FlagMessage, hljsIssueIndicatorPlugin, hljsUnderlinePlugin } from 'design-system'; +import React, { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter'; +import { + JupyterCodeCell, + 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 { 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'; import { useRawSourceQuery } from '../../queries/sources'; import { BranchLike } from '../../types/branch-like'; +import { Component, Issue } from '../../types/types'; +import LineIssuesIndicator from './components/LineIssuesIndicator'; +import loadIssues from './helpers/loadIssues'; export interface Props { branchLike: BranchLike | undefined; component: string; } +type IssuesByCell = { [key: number]: IssuesByLine }; +type IssuesByLine = { + [line: number]: { + end: { cursorOffset: number; line: number }; + issue: Issue; + start: { cursorOffset: number; line: number }; + }[]; +}; +type IssueKeysByLine = { [line: number]: string[] }; + +const DELAY_FOR_PORTAL_INDEX_ELEMENT = 200; + export default function SourceViewerPreview(props: Readonly) { const { component, branchLike } = props; - + const [issues, setIssues] = useState([]); + const [issuesByCell, setIssuesByCell] = useState({}); + const [renderedCells, setRenderedCells] = useState([]); const { data, isLoading } = useRawSourceQuery({ key: component, ...getBranchLikeQuery(branchLike), }); + const { component: componentContext } = React.useContext(ComponentContext); + + const jupyterNotebook = useMemo(() => { + if (typeof data !== 'string') { + return null; + } + try { + return JSON.parse(data) as { cells: ICell[] }; + } catch (error) { + return null; + } + }, [data]); + + const [hasRendered, setHasRendered] = useState(false); + + useEffect(() => { + const fetchData = async () => { + const issues = await loadIssues(component, branchLike); + setIssues(issues); + }; + + fetchData(); + }, [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) { + setRenderedCells(null); + return; + } + + if (startOffset.cell !== endOffset.cell) { + setRenderedCells(null); + 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 }); + } + }); + + setRenderedCells(jupyterNotebook?.cells); + setIssuesByCell(newIssuesByCell); + }, [issues, data, jupyterNotebook]); if (isLoading) { return ; @@ -53,13 +167,134 @@ export default function SourceViewerPreview(props: Readonly) { ); } - const jupyterFile: { cells: ICell[] } = JSON.parse(data); + if (!renderedCells) { + return ( + + {translate('source_viewer.jupyter.preview.error')} + + ); + } + + return ( + <> + setHasRendered(true)} + /> + {hasRendered && issues && componentContext && branchLike && ( + + )} + + ); +} + +type JupyterNotebookProps = { + cells: ICell[]; + issuesByCell: IssuesByCell; + onRender: () => void; +}; + +function mapIssuesToIssueKeys(issuesByLine: IssuesByLine): IssueKeysByLine { + return Object.entries(issuesByLine).reduce((acc, [line, issues]) => { + acc[Number(line)] = issues.map(({ issue }) => issue.key); + return acc; + }, {} as IssueKeysByLine); +} + +function RenderJupyterNotebook({ cells, issuesByCell, onRender }: Readonly) { + useEffect(() => { + // the `issue-key-${issue.key}` need to be rendered before we trigger the IssueIndicators below + setTimeout(onRender, DELAY_FOR_PORTAL_INDEX_ELEMENT); + }, [onRender]); + + const buildCellsBlocks = useMemo(() => { + return cells.map((cell: ICell, index: number) => { + let sourceLines = Array.isArray(cell.source) ? cell.source : [cell.source]; + const issuesByLine = issuesByCell[index]; + if (!issuesByLine) { + return { + cell, + sourceLines, + }; + } + const issues = mapIssuesToIssueKeys(issuesByLine); + const flatIssues = Object.entries(issuesByLine).flatMap(([, issues]) => issues); + + sourceLines = hljsUnderlinePlugin.tokenize(sourceLines, flatIssues); + sourceLines = hljsIssueIndicatorPlugin.addIssuesToLines(sourceLines, issues); + + return { + cell, + sourceLines, + }; + }); + }, [cells, issuesByCell]); return ( <> - {jupyterFile.cells.map((cell: ICell, index: number) => ( - - ))} + {buildCellsBlocks.map((element, index) => { + const { cell, sourceLines } = element; + if (isCode(cell)) { + return ( + + ); + } else if (isMarkdown(cell)) { + return ; + } + return null; + })} ); } + +type IssueIndicatorsProps = { + branchLike: BranchLike; + component: Component; + issuesByCell: IssuesByCell; +}; + +function IssueIndicators({ issuesByCell, component, branchLike }: Readonly) { + const location = useLocation(); + const query = parseQuery(location.query); + const router = useRouter(); + + const issuePortals = Object.entries(issuesByCell).flatMap(([, issuesByLine]) => + Object.entries(issuesByLine).map(([lineIndex, issues]) => { + const firstIssue = issues[0].issue; + const onlyIssues = issues.map(({ issue }) => issue); + const urlQuery = { + ...getBranchLikeQuery(branchLike), + ...serializeQuery(query), + open: firstIssue.key, + }; + const issueUrl = component?.key + ? getComponentIssuesUrl(component?.key, urlQuery) + : getIssuesUrl(urlQuery); + const portalIndexElement = document.getElementById(`issue-key-${firstIssue.key}`); + return portalIndexElement ? ( + + {createPortal( + router.navigate(issueUrl)} + line={{ line: Number(lineIndex) }} + as="span" + />, + portalIndexElement, + )} + + ) : null; + }), + ); + + return <>{issuePortals}; +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx index 250d19f6ca4..f8edb8c7191 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx @@ -27,6 +27,7 @@ import { Issue, SourceLine } from '../../../types/types'; const MOUSE_LEAVE_DELAY = 0.25; export interface LineIssuesIndicatorProps { + as?: React.ElementType; issues: Issue[]; issuesOpen?: boolean; line: SourceLine; @@ -34,7 +35,7 @@ export interface LineIssuesIndicatorProps { } export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { - const { issues, issuesOpen, line } = props; + const { issues, issuesOpen, line, as = 'td' } = props; const hasIssues = issues.length > 0; const intl = useIntl(); @@ -66,7 +67,7 @@ export function LineIssuesIndicator(props: LineIssuesIndicatorProps) { } return ( - + ) { return null; } -export function JupyterCodeCell({ cell }: Readonly<{ cell: ICodeCell }>) { - const snippet = isArray(cell.source) ? cell.source.join('') : cell.source; - +export function JupyterCodeCell({ + source, + outputs, +}: Readonly<{ outputs: IOutput[]; source: string[] }>) { return (
- +
- {cell.outputs?.map((output: IOutput, outputIndex: number) => ( - + {outputs?.map((output: IOutput, outputIndex: number) => ( + ))}
@@ -93,8 +93,9 @@ export function JupyterCodeCell({ cell }: Readonly<{ cell: ICodeCell }>) { } export function JupyterCell({ cell }: Readonly<{ cell: ICell }>) { + const source = Array.isArray(cell.source) ? cell.source : [cell.source]; if (isCode(cell)) { - return ; + return ; } if (isMarkdown(cell)) { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index ee1e16a539c..08aaaa994fe 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3628,6 +3628,7 @@ source_viewer.author_X=Author: {0} source_viewer.click_to_copy_filepath=Click to copy the filepath to clipboard source_viewer.issue_link_x={count} {quality} {count, plural, one {issue} other {issues}} source_viewer.jupyter.output.image=Output +source_viewer.jupyter.preview.error=Error while loading the Jupyter notebook. Use the Code tab to view raw. source_viewer.tooltip.duplicated_line=This line is duplicated. Click to see duplicated blocks. source_viewer.tooltip.duplicated_block=Duplicated block. Click for details. -- 2.39.5