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