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);
hljs.registerAliases('web', { languageName: 'xml' });
hljs.registerAliases(['cloudformation', 'kubernetes'], { languageName: 'yaml' });
+hljs.addPlugin(hljsIssueIndicatorPlugin);
hljs.addPlugin(hljsUnderlinePlugin);
interface Props {
--- /dev/null
+/*
+ * 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 };
--- /dev/null
+/*
+ * 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'));
+ });
+ });
+});
* 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';
],
source: ['import pylab as pl\n', '%matplotlib inline\n', 'pl.plot(x, y)'],
},
+ {
+ cell_type: 'markdown',
+ metadata: {},
+ source: '# markdown as a string',
+ },
],
});
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';
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,
});
it('should render correctly for ipynb files', async () => {
+ issuesHandler.setIssueList([JUPYTER_ISSUE]);
const component = mockComponent({
...componentsHandler.findComponentTree('foo')?.component,
qualifier: ComponentQualifier.Project,
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();
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) {
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>]'),
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();
* 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} />;
);
}
- 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}</>;
+}
const MOUSE_LEAVE_DELAY = 0.25;
export interface LineIssuesIndicatorProps {
+ as?: React.ElementType;
issues: Issue[];
issuesOpen?: boolean;
line: SourceLine;
}
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();
}
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}
import {
ICell,
- ICodeCell,
IMarkdownCell,
IOutput,
isCode,
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>
}
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)) {
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.