diff options
author | Lucas <97296331+lucas-paulger-sonarsource@users.noreply.github.com> | 2024-07-22 12:25:55 +0300 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-08-13 20:02:46 +0000 |
commit | fe9c1ab62eec9e15f806c884f274c6d32a6c8f92 (patch) | |
tree | 43ecda108f514bb3b1d54c8619a64ffa41e5075d /server/sonar-web/src | |
parent | e6adb0980a1db8a356a7283c240a95b01a90a472 (diff) | |
download | sonarqube-fe9c1ab62eec9e15f806c884f274c6d32a6c8f92.tar.gz sonarqube-fe9c1ab62eec9e15f806c884f274c6d32a6c8f92.zip |
SONAR-22499 CodeViewer supports ipynb files (#11371)
Diffstat (limited to 'server/sonar-web/src')
8 files changed, 467 insertions, 3 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/SourcesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SourcesServiceMock.ts new file mode 100644 index 00000000000..660eca474fe --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/SourcesServiceMock.ts @@ -0,0 +1,43 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { getRawSource } from '../sources'; +import { mockIpynbFile } from './data/sources'; + +jest.mock('../sources'); + +export default class SourcesServiceMock { + constructor() { + jest.mocked(getRawSource).mockImplementation(this.handleGetRawSource); + } + + handleGetRawSource = () => { + return this.reply(mockIpynbFile); + }; + + reply<T>(response: T): Promise<T> { + return Promise.resolve(cloneDeep(response)); + } + + reset = () => { + return this; + }; +} 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 new file mode 100644 index 00000000000..d77794ecf00 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/data/sources.ts @@ -0,0 +1,87 @@ +/* + * 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 const mockIpynbFile = JSON.stringify({ + cells: [ + { + cell_type: 'markdown', + metadata: {}, + source: ['# Learning a cosine with keras'], + }, + { + cell_type: 'code', + execution_count: 2, + metadata: { + collapsed: false, + jupyter: { + outputs_hidden: false, + }, + }, + outputs: [ + { + name: 'stdout', + output_type: 'stream', + text: ['(7500,)\n', '(2500,)\n'], + }, + ], + source: [ + 'import numpy as np\n', + 'import sklearn.cross_validation as skcv\n', + '#x = np.linspace(0, 5*np.pi, num=10000, dtype=np.float32)\n', + 'x = np.linspace(0, 4*np.pi, num=10000, dtype=np.float32)\n', + 'y = np.cos(x)\n', + '\n', + 'train, test = skcv.train_test_split(np.arange(x.shape[0]))\n', + 'print train.shape\n', + 'print test.shape', + ], + }, + { + cell_type: 'code', + execution_count: 3, + metadata: { + collapsed: false, + jupyter: { + outputs_hidden: false, + }, + }, + outputs: [ + { + data: { + 'text/plain': ['[<matplotlib.lines.Line2D at 0x7fb588176b90>]'], + }, + execution_count: 3, + metadata: {}, + output_type: 'execute_result', + }, + { + data: { + 'image/png': + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAG0lEQVR4nGIJn1mo28/GzPDiV+yTNYAAAAD//yPBBfrGshAGAAAAAElFTkSuQmCC', + 'text/plain': ['<matplotlib.figure.Figure at 0x7fb58e57c850>'], + }, + metadata: {}, + output_type: 'display_data', + }, + ], + source: ['import pylab as pl\n', '%matplotlib inline\n', 'pl.plot(x, y)'], + }, + ], +}); diff --git a/server/sonar-web/src/main/js/api/sources.ts b/server/sonar-web/src/main/js/api/sources.ts new file mode 100644 index 00000000000..fade6f713a1 --- /dev/null +++ b/server/sonar-web/src/main/js/api/sources.ts @@ -0,0 +1,24 @@ +/* + * 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 { get, parseText, RequestData } from '../helpers/request'; + +export function getRawSource(data: RequestData): Promise<string> { + return get('/api/sources/raw', data).then(parseText); +} 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 e19565fd7c4..156bbafe15b 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 @@ -27,9 +27,11 @@ import { MetricKey } from '~sonar-aligned/types/metrics'; 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 { 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 { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; import { Component } from '../../../types/types'; @@ -54,6 +56,7 @@ const originalScrollTo = window.scrollTo; const branchesHandler = new BranchesServiceMock(); const componentsHandler = new ComponentsServiceMock(); +const sourcesHandler = new SourcesServiceMock(); const issuesHandler = new IssuesServiceMock(); beforeAll(() => { @@ -75,6 +78,7 @@ afterAll(() => { beforeEach(() => { branchesHandler.reset(); componentsHandler.reset(); + sourcesHandler.reset(); issuesHandler.reset(); }); @@ -138,7 +142,7 @@ it('should behave correctly when using search', async () => { expect(await ui.searchResult(/folderA/).find()).toBeInTheDocument(); }); -it('should correcly handle long lists of components', async () => { +it('should correctly handle long lists of components', async () => { const component = mockComponent(componentsHandler.findComponentTree('foo')?.component); componentsHandler.registerComponentTree({ component, @@ -434,6 +438,56 @@ it('should correctly show new VS overall measures for Portfolios', async () => { }); }); +it('should render correctly for ipynb files', async () => { + const component = mockComponent({ + ...componentsHandler.findComponentTree('foo')?.component, + qualifier: ComponentQualifier.Project, + canBrowseAllChildProjects: true, + }); + componentsHandler.sourceFiles = [ + { + component: mockSourceViewerFile('file0.ipynb', 'foo'), + lines: times(1, (n) => + mockSourceLine({ + line: n, + code: 'function Test() {}', + }), + ), + }, + ]; + componentsHandler.registerComponentTree({ + component, + ancestors: [], + children: times(1, (n) => ({ + component: mockComponent({ + key: `foo:file${n}.ipynb`, + name: `file${n}.ipynb`, + qualifier: ComponentQualifier.File, + }), + ancestors: [component], + children: [], + })), + }); + const ui = getPageObject(userEvent.setup()); + renderCode({ component }); + + await ui.appLoaded(); + + await ui.clickOnChildComponent(/ipynb$/); + + expect(ui.previewToggle.get()).toBeInTheDocument(); + expect(ui.previewToggleOption().get()).toBeChecked(); + expect(ui.previewMarkdown.get()).toBeInTheDocument(); + expect(ui.previewCode.get()).toBeInTheDocument(); + expect(ui.previewOutputImage.get()).toBeInTheDocument(); + expect(ui.previewOutputText.get()).toBeInTheDocument(); + expect(ui.previewOutputStream.get()).toBeInTheDocument(); + + await ui.clickToggleCode(); + + expect(ui.sourceCode.get()).toBeInTheDocument(); +}); + function getPageObject(user: UserEvent) { const ui = { componentName: (name: string) => byText(name), @@ -442,8 +496,18 @@ function getPageObject(user: UserEvent) { componentIsEmptyTxt: (qualifier: ComponentQualifier) => byText(`code_viewer.no_source_code_displayed_due_to_empty_analysis.${qualifier}`), searchInput: byRole('searchbox'), + previewToggle: byRole('radiogroup'), + previewToggleOption: (name: string = 'Preview') => + byRole('radio', { + name, + }), noResultsTxt: byText('no_results'), sourceCode: byText('function Test() {}'), + previewCode: byText('numpy', { exact: false }), + previewMarkdown: byText('Learning a cosine with keras'), + previewOutputImage: byRole('img', { name: 'source_viewer.jupyter.output.image' }), + previewOutputText: byText('[<matplotlib.lines.Line2D at 0x7fb588176b90>]'), + previewOutputStream: byText('(7500,) (2500,)'), notAccessToAllChildrenTxt: byText('code_viewer.not_all_measures_are_shown'), showingOutOfTxt: (x: number, y: number) => byText(`x_of_y_shown.${x}.${y}`), newCodeBtn: byRole('radio', { name: 'projects.view.new_code' }), @@ -480,6 +544,9 @@ function getPageObject(user: UserEvent) { async clickOnChildComponent(name: string | RegExp) { await user.click(screen.getByRole('link', { name })); }, + async clickToggleCode() { + await user.click(ui.previewToggleOption('Code').get()); + }, async appLoaded(name = 'Foo') { await waitFor(() => { expect(ui.componentName(name).get()).toBeInTheDocument(); diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx index 2e23d1a35e3..219423b9c49 100644 --- a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx @@ -17,10 +17,12 @@ * 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 { Location } from '~sonar-aligned/types/router'; -import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation'; +import SourceViewer from '../../../components/SourceViewer/SourceViewer'; +import SourceViewerPreview from '../../../components/SourceViewer/SourceViewerPreview'; import { BranchLike } from '../../../types/branch-like'; import { Measure } from '../../../types/types'; @@ -31,8 +33,18 @@ export interface SourceViewerWrapperProps { location: Location; } +const PREVIEW_MODE_SUPPORTED_EXTENSIONS = ['ipynb']; + function SourceViewerWrapper(props: SourceViewerWrapperProps) { const { branchLike, component, componentMeasures, location } = props; + + const isPreviewSupported = React.useMemo( + () => PREVIEW_MODE_SUPPORTED_EXTENSIONS.includes(component.split('.').pop() ?? ''), + [component], + ); + + const [tab, setTab] = React.useState('preview'); + const { line } = location.query; const finalLine = line ? Number(line) : undefined; @@ -45,7 +57,34 @@ function SourceViewerWrapper(props: SourceViewerWrapperProps) { } }, [line]); - return ( + return isPreviewSupported ? ( + <> + <div className="sw-mb-4"> + <ToggleButton + options={[ + { label: 'Preview', value: 'preview' }, + { label: 'Code', value: 'code' }, + ]} + value={tab} + onChange={(value) => setTab(value)} + /> + </div> + + {tab === 'preview' ? ( + <SourceViewerPreview branchLike={branchLike} component={component} /> + ) : ( + <SourceViewer + aroundLine={finalLine} + branchLike={branchLike} + component={component} + componentMeasures={componentMeasures} + highlightedLine={finalLine} + onLoaded={handleLoaded} + showMeasures + /> + )} + </> + ) : ( <SourceViewer aroundLine={finalLine} branchLike={branchLike} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx new file mode 100644 index 00000000000..b4238ca4cca --- /dev/null +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerPreview.tsx @@ -0,0 +1,73 @@ +/* + * 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 { ICell, isCode, isMarkdown } 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 { 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 interface Props { + branchLike: BranchLike | undefined; + component: string; +} + +export default function SourceViewerPreview(props: Readonly<Props>) { + const { component, branchLike } = props; + + const { data, isLoading } = useRawSourceQuery( + omitNil({ key: component, ...getBranchLikeQuery(branchLike) }), + ); + + if (isLoading) { + return <Spinner isLoading={isLoading} />; + } + + if (typeof data !== 'string') { + return ( + <FlagMessage className="sw-mt-2" variant="warning"> + {translate('component_viewer.no_component')} + </FlagMessage> + ); + } + + const jupyterFile: { cells: ICell[] } = JSON.parse(data); + + 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; + })} + </> + ); +} diff --git a/server/sonar-web/src/main/js/queries/sources.ts b/server/sonar-web/src/main/js/queries/sources.ts new file mode 100644 index 00000000000..1b2c217f146 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/sources.ts @@ -0,0 +1,41 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { getRawSource } from '../api/sources'; +import { RequestData } from '../helpers/request'; + +function getIssuesQueryKey(data: RequestData) { + return ['issues', JSON.stringify(data ?? '')]; +} + +function fetchRawSources({ queryKey: [, query] }: { queryKey: string[] }) { + if (typeof query !== 'string') { + return null; + } + + return getRawSource(JSON.parse(query)); +} + +export function useRawSourceQuery(data: RequestData) { + return useQuery({ + queryKey: getIssuesQueryKey(data), + queryFn: fetchRawSources, + }); +} diff --git a/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx b/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx new file mode 100644 index 00000000000..9407cf73e43 --- /dev/null +++ b/server/sonar-web/src/main/js/sonar-aligned/components/SourceViewer/JupyterNotebookViewer.tsx @@ -0,0 +1,90 @@ +/* + * 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 { + ICodeCell, + IMarkdownCell, + IOutput, + isDisplayData, + isExecuteResult, + isStream, +} from '@jupyterlab/nbformat'; +import { CodeSnippet } from 'design-system/lib'; +import { isArray } from 'lodash'; +import React from 'react'; +import Markdown from 'react-markdown'; +import { Image } from '../../../components/common/Image'; +import { translate } from '../../../helpers/l10n'; + +export function JupyterMarkdownCell({ cell }: Readonly<{ cell: IMarkdownCell }>) { + const markdown = isArray(cell.source) ? cell.source.join('') : cell.source; + return ( + <div className="sw-m-4 sw-ml-0"> + <Markdown>{markdown}</Markdown> + </div> + ); +} + +function CellOutput({ output }: Readonly<{ output: IOutput }>) { + if (isExecuteResult(output) || isDisplayData(output)) { + const components = Object.entries(output.data).map(([mimeType, dataValue], index) => { + if (mimeType === 'image/png') { + return ( + <Image + src={`data:image/png;base64,${dataValue}`} + alt={translate('source_viewer.jupyter.output.image')} + key={`${mimeType}_${index}`} + /> + ); + } else if (mimeType === 'text/plain') { + const bundle = isArray(dataValue) ? dataValue.join('') : dataValue; + + return ( + <pre key={`${mimeType}_${index}`}> + {typeof bundle === 'string' ? bundle : JSON.stringify(bundle)} + </pre> + ); + } + return null; + }); + return components; + } else if (isStream(output)) { + const text = isArray(output.text) ? output.text.join('') : output.text; + return <pre>{text}</pre>; + } + return null; +} + +export function JupyterCodeCell({ cell }: Readonly<{ cell: ICodeCell }>) { + const snippet = isArray(cell.source) ? cell.source.join('') : cell.source; + + return ( + <div className="sw-m-4 sw-ml-0"> + <div> + <CodeSnippet language="python" noCopy snippet={snippet} wrap className="sw-p-4" /> + </div> + <div> + {cell.outputs?.map((output: IOutput, outputIndex: number) => ( + <CellOutput key={`${cell.cell_type}-output-${outputIndex}`} output={output} /> + ))} + </div> + </div> + ); +} |