jest.mock('../sources');
export default class SourcesServiceMock {
+ private source: string;
+
constructor() {
+ this.source = mockIpynbFile;
+
jest.mocked(getRawSource).mockImplementation(this.handleGetRawSource);
}
+ setSource(source: string) {
+ this.source = source;
+ }
+
handleGetRawSource = () => {
- return this.reply(mockIpynbFile);
+ return this.reply(this.source);
};
reply<T>(response: T): Promise<T> {
}
reset = () => {
- return this;
+ this.source = mockIpynbFile;
};
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { get, parseText, RequestData } from '../helpers/request';
+import { get, parseText } from '../helpers/request';
+import { BranchParameters } from '../sonar-aligned/types/branch-like';
-export function getRawSource(data: RequestData): Promise<string> {
+export function getRawSource(data: BranchParameters & { key: string }): Promise<string> {
return get('/api/sources/raw', data).then(parseText);
}
*/
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { keyBy, times } from 'lodash';
import { byLabelText, byRole } from '~sonar-aligned/helpers/testSelector';
+import { PARENT_COMPONENT_KEY, RULE_1 } from '../../../api/mocks/data/ids';
+import { mockSnippetsByComponent } from '../../../helpers/mocks/sources';
+import { mockRawIssue } from '../../../helpers/testMocks';
+import { IssueStatus } from '../../../types/issues';
import {
componentsHandler,
issuesHandler,
renderProjectIssuesApp,
+ sourcesHandler,
usersHandler,
waitOnDataLoaded,
} from '../test-utils';
beforeEach(() => {
issuesHandler.reset();
componentsHandler.reset();
+ sourcesHandler.reset();
usersHandler.reset();
window.scrollTo = jest.fn();
window.HTMLElement.prototype.scrollTo = jest.fn();
expandLinesAbove: byRole('button', { name: 'source_viewer.expand_above' }),
expandLinesBelow: byRole('button', { name: 'source_viewer.expand_below' }),
+ preview: byRole('radio', { name: 'preview' }),
+ code: byRole('radio', { name: 'code' }),
+
line1: byLabelText('source_viewer.line_X.1'),
line44: byLabelText('source_viewer.line_X.44'),
line45: byLabelText('source_viewer.line_X.45'),
),
};
+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: 1142,
+ endOffset: 1144,
+ },
+ 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',
+ ),
+};
+
describe('issues source viewer', () => {
it('should show source across components', async () => {
const user = userEvent.setup();
// eslint-disable-next-line jest-dom/prefer-in-document
expect(screen.getAllByRole('table')).toHaveLength(1);
});
+
+ describe('should render jupyter notebook issues correctly', () => {
+ it('should render error when jupyter issue can not be parsed', async () => {
+ issuesHandler.setIssueList([JUPYTER_ISSUE]);
+ sourcesHandler.setSource('{not a JSON file}');
+ renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject');
+ await waitOnDataLoaded();
+
+ // Preview tab should be shown
+ expect(ui.preview.get()).toBeChecked();
+ expect(ui.code.get()).toBeInTheDocument();
+
+ expect(
+ await screen.findByRole('button', { name: 'Issue on Jupyter Notebook' }),
+ ).toBeInTheDocument();
+
+ expect(screen.getByText('issue.preview.jupyter_notebook.error')).toBeInTheDocument();
+ });
+
+ it('should show preview tab when jupyter notebook issue', async () => {
+ issuesHandler.setIssueList([JUPYTER_ISSUE]);
+ renderProjectIssuesApp('project/issues?issues=some-issue&open=some-issue&id=myproject');
+ await waitOnDataLoaded();
+
+ // Preview tab should be shown
+ expect(ui.preview.get()).toBeChecked();
+ expect(ui.code.get()).toBeInTheDocument();
+
+ expect(
+ await screen.findByRole('button', { name: 'Issue on Jupyter Notebook' }),
+ ).toBeInTheDocument();
+
+ expect(screen.queryByText('issue.preview.jupyter_notebook.error')).not.toBeInTheDocument();
+ });
+ });
});
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { ToggleButton } from 'design-system/lib';
import * as React from 'react';
+import { isJupyterNotebookFile } from '~sonar-aligned/helpers/component';
+import { translate } from '../../../helpers/l10n';
import { BranchLike } from '../../../types/branch-like';
import { Issue } from '../../../types/types';
import CrossComponentSourceViewer from '../crossComponentSourceViewer/CrossComponentSourceViewer';
+import { JupyterNotebookIssueViewer } from '../jupyter-notebook/JupyterNotebookIssueViewer';
import { getLocations, getSelectedLocation } from '../utils';
import { IssueSourceViewerScrollContext } from './IssueSourceViewerScrollContext';
selectedLocationIndex: number | undefined;
}
-export default class IssuesSourceViewer extends React.PureComponent<IssuesSourceViewerProps> {
- primaryLocationRef?: HTMLElement;
- selectedSecondaryLocationRef?: HTMLElement;
+export default function IssuesSourceViewer(props: Readonly<IssuesSourceViewerProps>) {
+ const {
+ openIssue,
+ selectedFlowIndex,
+ selectedLocationIndex,
+ locationsNavigator,
+ branchLike,
+ issues,
+ onIssueSelect,
+ onLocationSelect,
+ } = props;
- componentDidUpdate() {
- if (this.props.selectedLocationIndex === -1) {
- this.refreshScroll();
- }
- }
-
- registerPrimaryLocationRef = (ref: HTMLElement) => {
- this.primaryLocationRef = ref;
-
- if (ref) {
- this.refreshScroll();
- }
- };
+ const [primaryLocationRef, setPrimaryLocationRef] = React.useState<HTMLElement | null>(null);
+ const [selectedSecondaryLocationRef, setSelectedSecondaryLocationRef] =
+ React.useState<HTMLElement | null>(null);
- registerSelectedSecondaryLocationRef = (ref: HTMLElement) => {
- this.selectedSecondaryLocationRef = ref;
-
- if (ref) {
- this.refreshScroll();
- }
- };
-
- refreshScroll() {
- const { selectedLocationIndex } = this.props;
+ const isJupyterNotebook = isJupyterNotebookFile(openIssue.component);
+ const [tab, setTab] = React.useState(isJupyterNotebook ? 'preview' : 'code');
+ const refreshScroll = React.useCallback(() => {
if (
selectedLocationIndex !== undefined &&
selectedLocationIndex !== -1 &&
- this.selectedSecondaryLocationRef
+ selectedSecondaryLocationRef
) {
- this.selectedSecondaryLocationRef.scrollIntoView({
+ selectedSecondaryLocationRef.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
- } else if (this.primaryLocationRef) {
- this.primaryLocationRef.scrollIntoView({
+ } else if (primaryLocationRef) {
+ primaryLocationRef.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
+ }, [selectedSecondaryLocationRef, primaryLocationRef, selectedLocationIndex]);
+
+ function registerPrimaryLocationRef(ref: HTMLElement) {
+ setPrimaryLocationRef(ref);
+
+ if (ref) {
+ refreshScroll();
+ }
+ }
+
+ function registerSelectedSecondaryLocationRef(ref: HTMLElement) {
+ setSelectedSecondaryLocationRef(ref);
+
+ if (ref) {
+ refreshScroll();
+ }
}
- render() {
- const {
- openIssue,
- selectedFlowIndex,
- selectedLocationIndex,
- locationsNavigator,
- branchLike,
- issues,
- } = this.props;
+ React.useEffect(() => {
+ if (selectedLocationIndex === -1) {
+ refreshScroll();
+ }
+ }, [selectedLocationIndex, refreshScroll]);
- const locations = getLocations(openIssue, selectedFlowIndex).map((loc, index) => {
- loc.index = index;
- return loc;
- });
+ const locations = getLocations(openIssue, selectedFlowIndex).map((loc, index) => {
+ loc.index = index;
+ return loc;
+ });
- const selectedLocation = getSelectedLocation(
- openIssue,
- selectedFlowIndex,
- selectedLocationIndex,
- );
+ const selectedLocation = getSelectedLocation(openIssue, selectedFlowIndex, selectedLocationIndex);
- const highlightedLocationMessage =
- locationsNavigator && selectedLocationIndex !== undefined
- ? selectedLocation && { index: selectedLocationIndex, text: selectedLocation.msg }
- : undefined;
+ const highlightedLocationMessage =
+ locationsNavigator && selectedLocationIndex !== undefined
+ ? selectedLocation && { index: selectedLocationIndex, text: selectedLocation.msg }
+ : undefined;
- return (
- <IssueSourceViewerScrollContext.Provider
- value={{
- registerPrimaryLocationRef: this.registerPrimaryLocationRef,
- registerSelectedSecondaryLocationRef: this.registerSelectedSecondaryLocationRef,
- }}
- >
- <CrossComponentSourceViewer
- branchLike={branchLike}
- highlightedLocationMessage={highlightedLocationMessage}
- issue={openIssue}
- issues={issues}
- locations={locations}
- onIssueSelect={this.props.onIssueSelect}
- onLocationSelect={this.props.onLocationSelect}
- selectedFlowIndex={selectedFlowIndex}
- />
- </IssueSourceViewerScrollContext.Provider>
- );
- }
+ return (
+ <>
+ {isJupyterNotebook && (
+ <div className="sw-mb-2">
+ <ToggleButton
+ options={[
+ { label: translate('preview'), value: 'preview' },
+ { label: translate('code'), value: 'code' },
+ ]}
+ value={tab}
+ onChange={(value) => setTab(value)}
+ />
+ </div>
+ )}
+ {tab === 'code' ? (
+ <IssueSourceViewerScrollContext.Provider
+ value={{
+ registerPrimaryLocationRef,
+ registerSelectedSecondaryLocationRef,
+ }}
+ >
+ <CrossComponentSourceViewer
+ branchLike={branchLike}
+ highlightedLocationMessage={highlightedLocationMessage}
+ issue={openIssue}
+ issues={issues}
+ locations={locations}
+ onIssueSelect={onIssueSelect}
+ onLocationSelect={onLocationSelect}
+ selectedFlowIndex={selectedFlowIndex}
+ />
+ </IssueSourceViewerScrollContext.Provider>
+ ) : (
+ <JupyterNotebookIssueViewer branchLike={branchLike} issue={openIssue} />
+ )}
+ </>
+ );
}
--- /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 { INotebookContent } from '@jupyterlab/nbformat';
+import { Spinner } from '@sonarsource/echoes-react';
+import { FlagMessage, IssueMessageHighlighting, LineFinding } from 'design-system';
+import React, { useMemo } from 'react';
+import { JupyterCell } from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer';
+import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
+import { JsonIssueMapper } from '~sonar-aligned/helpers/json-issue-mapper';
+import { translate } from '../../../helpers/l10n';
+import { useRawSourceQuery } from '../../../queries/sources';
+import { BranchLike } from '../../../types/branch-like';
+import { Issue } from '../../../types/types';
+import { JupyterNotebookCursorPath } from './types';
+import { pathToCursorInCell } from './utils';
+
+export interface JupyterNotebookIssueViewerProps {
+ branchLike?: BranchLike;
+ issue: Issue;
+}
+
+export function JupyterNotebookIssueViewer(props: Readonly<JupyterNotebookIssueViewerProps>) {
+ const { issue, branchLike } = props;
+ const { data, isLoading } = useRawSourceQuery({
+ key: issue.component,
+ ...getBranchLikeQuery(branchLike),
+ });
+ const [startOffset, setStartOffset] = React.useState<JupyterNotebookCursorPath | null>(null);
+ const [endPath, setEndPath] = React.useState<JupyterNotebookCursorPath | null>(null);
+
+ const jupyterNotebook = useMemo(() => {
+ if (typeof data !== 'string') {
+ return null;
+ }
+ try {
+ return JSON.parse(data) as INotebookContent;
+ } catch (error) {
+ return null;
+ }
+ }, [data]);
+
+ React.useEffect(() => {
+ if (typeof data !== 'string') {
+ return;
+ }
+
+ if (!issue.textRange) {
+ 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 &&
+ startOffset.cell === endOffset.cell &&
+ startOffset.line === endOffset.line
+ ) {
+ setStartOffset(startOffset);
+ setEndPath(endOffset);
+ }
+ }, [issue, data]);
+
+ if (isLoading) {
+ return <Spinner />;
+ }
+
+ if (!jupyterNotebook || !startOffset || !endPath) {
+ return (
+ <FlagMessage className="sw-mt-2" variant="warning">
+ {translate('issue.preview.jupyter_notebook.error')}
+ </FlagMessage>
+ );
+ }
+
+ // Cells to display
+ const cells = Array.from(new Set([startOffset.cell, endPath.cell])).map(
+ (cellIndex) => jupyterNotebook.cells[cellIndex],
+ );
+
+ return (
+ <>
+ <LineFinding
+ issueKey={issue.key}
+ message={
+ <IssueMessageHighlighting
+ message={issue.message}
+ messageFormattings={issue.messageFormattings}
+ />
+ }
+ selected
+ />
+ {cells.map((cell, index) => (
+ <JupyterCell key={'cell-' + index} cell={cell} />
+ ))}
+ </>
+ );
+}
--- /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.
+ */
+export interface JupyterNotebookOutput {
+ data: {
+ [key: string]: string | string[];
+ 'image/png': string;
+ 'text/html': string[];
+ 'text/plain': string[];
+ };
+ metadata: { [key: string]: string };
+ output_type: string;
+ text?: string[];
+}
+
+export interface JupyterNotebookCursorPath {
+ cell: number;
+ cursorOffset: number;
+ line: number;
+}
--- /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 { PathToCursor } from '~sonar-aligned/helpers/json-issue-mapper';
+
+export function pathToCursorInCell(path: PathToCursor): {
+ cell: number;
+ cursorOffset: number;
+ line: number;
+} | null {
+ const [, cellEntry, , lineEntry, stringEntry] = path;
+ if (
+ cellEntry?.type !== 'array' ||
+ lineEntry?.type !== 'array' ||
+ stringEntry?.type !== 'string'
+ ) {
+ return null;
+ }
+ return {
+ cell: cellEntry.index,
+ line: lineEntry.index,
+ cursorOffset: stringEntry.index,
+ };
+}
import BranchesServiceMock from '../../api/mocks/BranchesServiceMock';
import ComponentsServiceMock from '../../api/mocks/ComponentsServiceMock';
import IssuesServiceMock from '../../api/mocks/IssuesServiceMock';
+import SourcesServiceMock from '../../api/mocks/SourcesServiceMock';
import UsersServiceMock from '../../api/mocks/UsersServiceMock';
import { mockComponent } from '../../helpers/mocks/component';
import { mockCurrentUser } from '../../helpers/testMocks';
export const usersHandler = new UsersServiceMock();
export const issuesHandler = new IssuesServiceMock(usersHandler);
export const componentsHandler = new ComponentsServiceMock();
+export const sourcesHandler = new SourcesServiceMock();
export const branchHandler = new BranchesServiceMock();
export const ui = {
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { ICell, isCode, isMarkdown } from '@jupyterlab/nbformat';
+import { ICell } from '@jupyterlab/nbformat';
import { Spinner } from '@sonarsource/echoes-react';
import { FlagMessage } from 'design-system/lib';
import React from 'react';
-import {
- JupyterCodeCell,
- JupyterMarkdownCell,
-} from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer';
+import { JupyterCell } from '~sonar-aligned/components/SourceViewer/JupyterNotebookViewer';
import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
import { translate } from '../../helpers/l10n';
-import { omitNil } from '../../helpers/request';
import { useRawSourceQuery } from '../../queries/sources';
import { BranchLike } from '../../types/branch-like';
export default function SourceViewerPreview(props: Readonly<Props>) {
const { component, branchLike } = props;
- const { data, isLoading } = useRawSourceQuery(
- omitNil({ key: component, ...getBranchLikeQuery(branchLike) }),
- );
+ const { data, isLoading } = useRawSourceQuery({
+ key: component,
+ ...getBranchLikeQuery(branchLike),
+ });
if (isLoading) {
return <Spinner isLoading={isLoading} />;
return (
<>
- {jupyterFile.cells.map((cell: ICell, index: number) => {
- if (isCode(cell)) {
- return <JupyterCodeCell cell={cell} key={`${cell.cell_type}-${index}`} />;
- } else if (isMarkdown(cell)) {
- return <JupyterMarkdownCell cell={cell} key={`${cell.cell_type}-${index}`} />;
- }
- return null;
- })}
+ {jupyterFile.cells.map((cell: ICell, index: number) => (
+ <JupyterCell cell={cell} key={`${cell.cell_type}-${index}`} />
+ ))}
</>
);
}
import { useQuery } from '@tanstack/react-query';
import { getRawSource } from '../api/sources';
import { RequestData } from '../helpers/request';
+import { BranchParameters } from '../sonar-aligned/types/branch-like';
function getIssuesQueryKey(data: RequestData) {
return ['issues', JSON.stringify(data ?? '')];
return null;
}
- return getRawSource(JSON.parse(query));
+ return getRawSource(JSON.parse(query) as BranchParameters & { key: string });
}
-export function useRawSourceQuery(data: RequestData) {
+export function useRawSourceQuery(data: BranchParameters & { key: string }) {
return useQuery({
queryKey: getIssuesQueryKey(data),
queryFn: fetchRawSources,
*/
import {
+ ICell,
ICodeCell,
IMarkdownCell,
IOutput,
+ isCode,
isDisplayData,
isExecuteResult,
+ isMarkdown,
isStream,
} from '@jupyterlab/nbformat';
import { CodeSnippet } from 'design-system/lib';
</div>
);
}
+
+export function JupyterCell({ cell }: Readonly<{ cell: ICell }>) {
+ if (isCode(cell)) {
+ return <JupyterCodeCell cell={cell} />;
+ }
+
+ if (isMarkdown(cell)) {
+ return <JupyterMarkdownCell cell={cell} />;
+ }
+
+ return null;
+}
componentQualifier === ComponentQualifier.SubPortfolio
);
}
+
+export function isJupyterNotebookFile(componentKey: string) {
+ return componentKey.endsWith('.ipynb');
+}
path=Path
permalink=Permanent Link
plugin=Plugin
+preview=Preview
previous=Previous
previous_=previous
previous_month_x=previous month {month}
issue.resolution.REMOVED.description=Either the rule or the resource was changed (removed, relocated, parameters changed, etc.) so that analysis no longer finds these issues.
issue.unresolved.description=Unresolved issues have not been addressed in any way.
+issue.preview.jupyter_notebook.error=Error while loading the Jupyter notebook. Use the Code tab to see issue details.
+
issue.action.permalink=Get permalink
issue.line_affected=Line affected:
issue.introduced=Introduced: