]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22498 CodeSnippet supports issue indicator for jupyter preview (#11414)
authorLucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com>
Mon, 29 Jul 2024 12:47:33 +0000 (15:47 +0300)
committersonartech <sonartech@sonarsource.com>
Tue, 13 Aug 2024 20:02:46 +0000 (20:02 +0000)
server/sonar-web/design-system/src/components/CodeSyntaxHighlighter.tsx
server/sonar-web/design-system/src/sonar-aligned/hljs/HljsIssueIndicatorPlugin.ts [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/hljs/__tests__/HljsIssueIndicatorPlugin-test.ts [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/hljs/index.ts
server/sonar-web/src/main/js/api/mocks/data/sources.ts
server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx
server/sonar-web/src/main/js/components/SourceViewer/components/LineIssuesIndicator.tsx
server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 502cc4a9eaebc0fbc0c2d1cac9335e89e935538c..1a35a36d10a32dc31f2023d97ac19c0b643f0ea8 100644 (file)
@@ -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 (file)
index 0000000..08d32b3
--- /dev/null
@@ -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 = `<div style="${this.LINE_WRAPPER_STYLE}">`;
+  static readonly LINE_WRAPPER_CLOSE_TAG = `</div>`;
+  static readonly EMPTY_INDICATOR_COLUMN = `<div></div>`;
+  public lineIssueIndicatorElement(issueKey: string) {
+    return `<div id="issue-key-${issueKey}"></div>`;
+  }
+
+  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),
+          '<div>',
+          line,
+          '</div>',
+          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,
+        '<div>',
+        line,
+        '</div>',
+        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 (file)
index 0000000..2760ee2
--- /dev/null
@@ -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}<div>line1</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}<div id="issue-key-123abd"></div><div>line2</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line3</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line4</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line5</div>${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}<div>line1</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}<div id="issue-key-123abd"></div><div>line2 issue2</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line3</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line4</div>${HljsIssueIndicatorPlugin.LINE_WRAPPER_CLOSE_TAG}`,
+          `${HljsIssueIndicatorPlugin.LINE_WRAPPER_OPEN_TAG}${HljsIssueIndicatorPlugin.EMPTY_INDICATOR_COLUMN}<div>line5</div>${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'));
+    });
+  });
+});
index b73946667773367d6d6aa3651435de6cc6c59e49..0816564955e1c0bd16ed3654e535136c24669fb9 100644 (file)
@@ -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';
index d77794ecf00c1ca84a6fa64cbd5547cd4cc9fcf3..7b1b1d28486c0bd2648410f94060c63f7f291845 100644 (file)
@@ -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',
+    },
   ],
 });
index 156bbafe15b3ec37a88fcf21bf59e88b40645491..0bc8b5494ab9ef6c81f3c3213ad7c7f195ecb831 100644 (file)
@@ -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('[<matplotlib.lines.Line2D at 0x7fb588176b90>]'),
@@ -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();
index 1a1fa036e18a01634f51a69ad6ad504d28e065b1..e56b792194eff1c961278b7b5a9544a5029d4cf6 100644 (file)
  * 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<Props>) {
   const { component, branchLike } = props;
-
+  const [issues, setIssues] = useState<Issue[]>([]);
+  const [issuesByCell, setIssuesByCell] = useState<IssuesByCell>({});
+  const [renderedCells, setRenderedCells] = useState<ICell[] | null>([]);
   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 <Spinner isLoading={isLoading} />;
@@ -53,13 +167,134 @@ export default function SourceViewerPreview(props: Readonly<Props>) {
     );
   }
 
-  const jupyterFile: { cells: ICell[] } = JSON.parse(data);
+  if (!renderedCells) {
+    return (
+      <FlagMessage className="sw-mt-2" variant="warning">
+        {translate('source_viewer.jupyter.preview.error')}
+      </FlagMessage>
+    );
+  }
+
+  return (
+    <>
+      <RenderJupyterNotebook
+        cells={renderedCells}
+        issuesByCell={issuesByCell}
+        onRender={() => setHasRendered(true)}
+      />
+      {hasRendered && issues && componentContext && branchLike && (
+        <IssueIndicators
+          issuesByCell={issuesByCell}
+          component={componentContext}
+          branchLike={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<JupyterNotebookProps>) {
+  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) => (
-        <JupyterCell cell={cell} key={`${cell.cell_type}-${index}`} />
-      ))}
+      {buildCellsBlocks.map((element, index) => {
+        const { cell, sourceLines } = element;
+        if (isCode(cell)) {
+          return (
+            <JupyterCodeCell
+              source={sourceLines}
+              outputs={cell.outputs}
+              key={`${cell.cell_type}-${index}`}
+            />
+          );
+        } else if (isMarkdown(cell)) {
+          return <JupyterMarkdownCell cell={cell} key={`${cell.cell_type}-${index}`} />;
+        }
+        return null;
+      })}
     </>
   );
 }
+
+type IssueIndicatorsProps = {
+  branchLike: BranchLike;
+  component: Component;
+  issuesByCell: IssuesByCell;
+};
+
+function IssueIndicators({ issuesByCell, component, branchLike }: Readonly<IssueIndicatorsProps>) {
+  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 ? (
+        <span key={`${firstIssue.key}-${lineIndex}`}>
+          {createPortal(
+            <LineIssuesIndicator
+              issues={onlyIssues}
+              onClick={() => router.navigate(issueUrl)}
+              line={{ line: Number(lineIndex) }}
+              as="span"
+            />,
+            portalIndexElement,
+          )}
+        </span>
+      ) : null;
+    }),
+  );
+
+  return <>{issuePortals}</>;
+}
index 250d19f6ca491dec7bf2c160266221abe082b05e..f8edb8c71913a0f3c70c2c94de12536162db23cf 100644 (file)
@@ -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 (
-    <LineMeta className="it__source-line-with-issues" data-line-number={line.line}>
+    <LineMeta className="it__source-line-with-issues" data-line-number={line.line} as={as}>
       <Tooltip mouseLeaveDelay={MOUSE_LEAVE_DELAY} content={tooltipContent}>
         <IssueIndicatorButton
           aria-label={tooltipContent}
index 52e0a86f04d6fa6a603430ac5b5fbcaffda30b69..a33fd67a17193cacd63e8cc30c0ec84f636b1005 100644 (file)
@@ -20,7 +20,6 @@
 
 import {
   ICell,
-  ICodeCell,
   IMarkdownCell,
   IOutput,
   isCode,
@@ -75,17 +74,18 @@ function CellOutput({ output }: Readonly<{ output: IOutput }>) {
   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 (
     <div className="sw-m-4 sw-ml-0">
       <div>
-        <CodeSnippet language="python" noCopy snippet={snippet} wrap className="sw-p-4" />
+        <CodeSnippet language="python" noCopy snippet={source.join('')} wrap className="sw-p-4" />
       </div>
       <div>
-        {cell.outputs?.map((output: IOutput, outputIndex: number) => (
-          <CellOutput key={`${cell.cell_type}-output-${outputIndex}`} output={output} />
+        {outputs?.map((output: IOutput, outputIndex: number) => (
+          <CellOutput key={`${output.output_type}-output-${outputIndex}`} output={output} />
         ))}
       </div>
     </div>
@@ -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 <JupyterCodeCell cell={cell} />;
+    return <JupyterCodeCell source={source} outputs={cell.outputs} />;
   }
 
   if (isMarkdown(cell)) {
index ee1e16a539ca84b42abea27a95144bf4e4f06bbb..08aaaa994feb6aaa095eb3ac16c831c152cf75fa 100644 (file)
@@ -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.