Browse Source

SONAR-12076 Add duplication popup back to the new multi location issue flow

tags/7.8
Grégoire Aubert 5 years ago
parent
commit
f2f3ede233
21 changed files with 788 additions and 126 deletions
  1. 4
    2
      server/sonar-web/src/main/js/api/components.ts
  2. 8
    1
      server/sonar-web/src/main/js/app/types.d.ts
  3. 42
    40
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx
  4. 138
    28
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx
  5. 54
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx
  6. 75
    10
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx
  7. 182
    0
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap
  8. 4
    1
      server/sonar-web/src/main/js/apps/issues/styles.css
  9. 15
    34
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx
  10. 3
    3
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx
  11. 3
    3
      server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx
  12. 2
    2
      server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx
  13. 1
    1
      server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx
  14. 1
    1
      server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx
  15. 66
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/__snapshots__/loadIssues-test.ts.snap
  16. 49
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/duplications-test.ts
  17. 92
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/loadIssues-test.ts
  18. 48
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/duplications.ts
  19. 0
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.ts
  20. 0
    0
      server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.ts
  21. 1
    0
      server/sonar-web/src/main/js/components/issue/Issue.css

+ 4
- 2
server/sonar-web/src/main/js/api/components.ts View File

@@ -276,8 +276,10 @@ export function getSources(
return getJSON('/api/sources/lines', data).then(r => r.sources);
}

export function getDuplications(data: { key: string } & T.BranchParameters): Promise<any> {
return getJSON('/api/duplications/show', data);
export function getDuplications(
data: { key: string } & T.BranchParameters
): Promise<{ duplications: T.Duplication[]; files: T.Dict<T.DuplicatedFile> }> {
return getJSON('/api/duplications/show', data).catch(throwGlobalError);
}

export function getTests(

+ 8
- 1
server/sonar-web/src/main/js/app/types.d.ts View File

@@ -257,7 +257,7 @@ declare namespace T {
}

export interface DuplicationBlock {
_ref: string;
_ref?: string;
from: number;
size: number;
}
@@ -419,6 +419,13 @@ declare namespace T {
[line: number]: SourceLine;
}

export interface LinePopup {
index?: number;
line: number;
name: string;
open?: boolean;
}

export interface LoggedInUser extends CurrentUser {
avatar?: string;
email?: string;

+ 42
- 40
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/ComponentSourceSnippetViewer.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import * as classNames from 'classnames';
import {
createSnippets,
expandSnippet,
@@ -26,11 +27,11 @@ import {
LINES_BELOW_LAST,
MERGE_DISTANCE
} from './utils';
import { getSources } from '../../../api/components';
import ExpandSnippetIcon from '../../../components/icons-components/ExpandSnippetIcon';
import Line from '../../../components/SourceViewer/components/Line';
import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
import { getSources } from '../../../api/components';
import { symbolsByLine, locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
import { getSecondaryIssueLocationsForLine } from '../../../components/SourceViewer/helpers/issueLocations';
import {
@@ -42,16 +43,25 @@ import { translate } from '../../../helpers/l10n';

interface Props {
branchLike: T.BranchLike | undefined;
duplications?: T.Duplication[];
duplicationsByLine?: { [line: number]: number[] };
highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
issue: T.Issue;
issuePopup?: { issue: string; name: string };
issuesByLine: T.IssuesByLine;
last: boolean;
linePopup?: T.LinePopup;
loadDuplications: (component: string, line: T.SourceLine) => void;
locations: T.FlowLocation[];
onIssueChange: (issue: T.Issue) => void;
onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
onLinePopupToggle: (linePopup: T.LinePopup & { component: string }) => void;
onLocationSelect: (index: number) => void;
renderDuplicationPopup: (index: number, line: number) => JSX.Element;
renderDuplicationPopup: (
component: T.SourceViewerFile,
index: number,
line: number
) => React.ReactNode;
scroll?: (element: HTMLElement) => void;
snippetGroup: T.SnippetGroup;
}
@@ -59,7 +69,6 @@ interface Props {
interface State {
additionalLines: { [line: number]: T.SourceLine };
highlightedSymbols: string[];
linePopup?: { index?: number; line: number; name: string };
loading: boolean;
openIssuesByLine: T.Dict<boolean>;
snippets: T.SourceLine[][];
@@ -140,7 +149,6 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr

return {
additionalLines: combinedLines,
linePopup: undefined,
snippets: expandSnippet({
direction,
lines: { ...combinedLines, ...this.props.snippetGroup.sources },
@@ -163,7 +171,7 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
getSources({ key }).then(
lines => {
if (this.mounted) {
this.setState({ linePopup: undefined, loading: false, snippets: [lines] });
this.setState({ loading: false, snippets: [lines] });
}
},
() => {
@@ -174,29 +182,10 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
);
};

handleLinePopupToggle = ({
index,
line,
name,
open
}: {
index?: number;
line: number;
name: string;
open?: boolean;
}) => {
this.setState((state: State) => {
const samePopup =
state.linePopup !== undefined &&
state.linePopup.name === name &&
state.linePopup.line === line &&
state.linePopup.index === index;
if (open !== false && !samePopup) {
return { linePopup: { index, line, name } };
} else if (open !== true && samePopup) {
return { linePopup: undefined };
}
return null;
handleLinePopupToggle = (linePopup: T.LinePopup) => {
this.props.onLinePopupToggle({
...linePopup,
component: this.props.snippetGroup.component.key
});
};

@@ -216,6 +205,14 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
this.setState({ highlightedSymbols });
};

loadDuplications = (line: T.SourceLine) => {
this.props.loadDuplications(this.props.snippetGroup.component.key, line);
};

renderDuplicationPopup = (index: number, line: number) => {
return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
};

renderLine({
index,
issuesForLine,
@@ -234,10 +231,12 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
verticalBuffer: number;
}) {
const { openIssuesByLine } = this.state;

const secondaryIssueLocations = getSecondaryIssueLocationsForLine(line, this.props.locations);

const noop = () => {};
const { duplications, duplicationsByLine } = this.props;
const duplicationsCount = duplications ? duplications.length : 0;
const lineDuplications =
(duplicationsCount && duplicationsByLine && duplicationsByLine[line.line]) || [];

const isSinkLine = issuesForLine.some(i => i.key === this.props.issue.key);

@@ -246,11 +245,11 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
branchLike={undefined}
displayAllIssues={false}
displayCoverage={true}
displayDuplications={false}
displayDuplications={!!line.duplicated}
displayIssues={!isSinkLine || issuesForLine.length > 1}
displayLocationMarkers={true}
duplications={[]}
duplicationsCount={0}
duplications={lineDuplications}
duplicationsCount={duplicationsCount}
highlighted={false}
highlightedLocationMessage={optimizeLocationMessage(
this.props.highlightedLocationMessage,
@@ -263,12 +262,12 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
key={line.line}
last={false}
line={line}
linePopup={this.state.linePopup}
loadDuplications={noop}
linePopup={this.props.linePopup}
loadDuplications={this.loadDuplications}
onIssueChange={this.props.onIssueChange}
onIssuePopupToggle={this.props.onIssuePopupToggle}
onIssueSelect={noop}
onIssueUnselect={noop}
onIssueSelect={() => {}}
onIssueUnselect={() => {}}
onIssuesClose={this.handleCloseIssues}
onIssuesOpen={this.handleOpenIssues}
onLinePopupToggle={this.handleLinePopupToggle}
@@ -276,7 +275,7 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
onSymbolClick={this.handleSymbolClick}
openIssues={openIssuesByLine[line.line]}
previousLine={index > 0 ? snippet[index - 1] : undefined}
renderDuplicationPopup={this.props.renderDuplicationPopup}
renderDuplicationPopup={this.renderDuplicationPopup}
scroll={this.props.scroll}
secondaryIssueLocations={secondaryIssueLocations}
selectedIssue={optimizeSelectedIssue(this.props.issue.key, issuesForLine)}
@@ -359,7 +358,7 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
}

render() {
const { branchLike, issue, issuesByLine, last, snippetGroup } = this.props;
const { branchLike, duplications, issue, issuesByLine, last, snippetGroup } = this.props;
const { loading, snippets } = this.state;
const locations = locationsByLine([issue]);

@@ -369,7 +368,10 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
snippets[0].length === parseInt(snippetGroup.component.measures.lines || '', 10);

return (
<div className="component-source-container">
<div
className={classNames('component-source-container', {
'source-duplications-expanded': duplications && duplications.length > 0
})}>
<SourceViewerHeaderSlim
branchLike={branchLike}
expandable={!fullyShown}

+ 138
- 28
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewerWrapper.tsx View File

@@ -21,14 +21,20 @@ import * as React from 'react';
import ComponentSourceSnippetViewer from './ComponentSourceSnippetViewer';
import { groupLocationsByComponent } from './utils';
import DeferredSpinner from '../../../components/common/DeferredSpinner';
import DuplicationPopup from '../../../components/SourceViewer/components/DuplicationPopup';
import { WorkspaceContext } from '../../../components/workspace/context';
import { getIssueFlowSnippets } from '../../../api/issues';
import { issuesByComponentAndLine } from '../../../components/SourceViewer/helpers/indexing';

interface State {
components: T.Dict<T.SnippetsByComponent>;
issuePopup?: { issue: string; name: string };
loading: boolean;
}
import {
filterDuplicationBlocksByLine,
isDuplicationBlockInRemovedComponent,
getDuplicationBlocksForIndex
} from '../../../components/SourceViewer/helpers/duplications';
import {
duplicationsByLine,
issuesByComponentAndLine
} from '../../../components/SourceViewer/helpers/indexing';
import { getDuplications } from '../../../api/components';
import { getBranchLikeQuery } from '../../../helpers/branches';

interface Props {
branchLike: T.Branch | T.PullRequest | undefined;
@@ -39,15 +45,25 @@ interface Props {
onIssueChange: (issue: T.Issue) => void;
onLoaded?: () => void;
onLocationSelect: (index: number) => void;
renderDuplicationPopup: (index: number, line: number) => JSX.Element;
scroll?: (element: HTMLElement) => void;
selectedFlowIndex: number | undefined;
}

interface State {
components: T.Dict<T.SnippetsByComponent>;
duplicatedFiles?: T.Dict<T.DuplicatedFile>;
duplications?: T.Duplication[];
duplicationsByLine: { [line: number]: number[] };
issuePopup?: { issue: string; name: string };
linePopup?: T.LinePopup & { component: string };
loading: boolean;
}

export default class CrossComponentSourceViewerWrapper extends React.PureComponent<Props, State> {
mounted = false;
state: State = {
components: {},
duplicationsByLine: {},
loading: true
};

@@ -66,12 +82,39 @@ export default class CrossComponentSourceViewerWrapper extends React.PureCompone
this.mounted = false;
}

fetchDuplications = (component: string, line: T.SourceLine) => {
getDuplications({
key: component,
...getBranchLikeQuery(this.props.branchLike)
}).then(
r => {
if (this.mounted) {
this.setState(state => ({
duplicatedFiles: r.files,
duplications: r.duplications,
duplicationsByLine: duplicationsByLine(r.duplications),
linePopup:
r.duplications.length === 1
? { component, index: 0, line: line.line, name: 'duplications' }
: state.linePopup
}));
}
},
() => {}
);
};

fetchIssueFlowSnippets(issueKey: string) {
this.setState({ loading: true });
getIssueFlowSnippets(issueKey).then(
components => {
if (this.mounted) {
this.setState({ components, issuePopup: undefined, loading: false });
this.setState({
components,
issuePopup: undefined,
linePopup: undefined,
loading: false
});
if (this.props.onLoaded) {
this.props.onLoaded();
}
@@ -98,8 +141,61 @@ export default class CrossComponentSourceViewerWrapper extends React.PureCompone
});
};

handleLinePopupToggle = ({
component,
index,
line,
name,
open
}: T.LinePopup & { component: string }) => {
this.setState((state: State) => {
const samePopup =
state.linePopup !== undefined &&
state.linePopup.line === line &&
state.linePopup.name === name &&
state.linePopup.component === component &&
state.linePopup.index === index;
if (open !== false && !samePopup) {
return { linePopup: { component, index, line, name } };
} else if (open !== true && samePopup) {
return { linePopup: undefined };
}
return null;
});
};

handleCloseLinePopup = () => {
this.setState({ linePopup: undefined });
};

renderDuplicationPopup = (component: T.SourceViewerFile, index: number, line: number) => {
const { duplicatedFiles, duplications } = this.state;

if (!component || !duplicatedFiles) {
return null;
}

const blocks = getDuplicationBlocksForIndex(duplications, index);

return (
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<DuplicationPopup
blocks={filterDuplicationBlocksByLine(blocks, line)}
branchLike={this.props.branchLike}
duplicatedFiles={duplicatedFiles}
inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)}
onClose={this.handleCloseLinePopup}
openComponent={openComponent}
sourceViewerFile={component}
/>
)}
</WorkspaceContext.Consumer>
);
};

render() {
const { components, loading } = this.state;
const { loading } = this.state;

if (loading) {
return (
@@ -109,29 +205,43 @@ export default class CrossComponentSourceViewerWrapper extends React.PureCompone
);
}

const { components, duplications, duplicationsByLine, linePopup } = this.state;
const issuesByComponent = issuesByComponentAndLine(this.props.issues);
const locationsByComponent = groupLocationsByComponent(this.props.locations, components);

return (
<div>
{locationsByComponent.map((g, i) => (
<ComponentSourceSnippetViewer
branchLike={this.props.branchLike}
highlightedLocationMessage={this.props.highlightedLocationMessage}
issue={this.props.issue}
issuePopup={this.state.issuePopup}
issuesByLine={issuesByComponent[g.component.key] || {}}
key={`${this.props.issue.key}-${this.props.selectedFlowIndex}-${i}`}
last={i === locationsByComponent.length - 1}
locations={g.locations || []}
onIssueChange={this.props.onIssueChange}
onIssuePopupToggle={this.handleIssuePopupToggle}
onLocationSelect={this.props.onLocationSelect}
renderDuplicationPopup={this.props.renderDuplicationPopup}
scroll={this.props.scroll}
snippetGroup={g}
/>
))}
{locationsByComponent.map((snippetGroup, i) => {
let componentProps = {};
if (linePopup && snippetGroup.component.key === linePopup.component) {
componentProps = {
duplications,
duplicationsByLine,
linePopup: { index: linePopup.index, line: linePopup.line, name: linePopup.name }
};
}
return (
<ComponentSourceSnippetViewer
branchLike={this.props.branchLike}
highlightedLocationMessage={this.props.highlightedLocationMessage}
issue={this.props.issue}
issuePopup={this.state.issuePopup}
issuesByLine={issuesByComponent[snippetGroup.component.key] || {}}
key={`${this.props.issue.key}-${this.props.selectedFlowIndex}-${i}`}
last={i === locationsByComponent.length - 1}
loadDuplications={this.fetchDuplications}
locations={snippetGroup.locations || []}
onIssueChange={this.props.onIssueChange}
onIssuePopupToggle={this.handleIssuePopupToggle}
onLinePopupToggle={this.handleLinePopupToggle}
onLocationSelect={this.props.onLocationSelect}
renderDuplicationPopup={this.renderDuplicationPopup}
scroll={this.props.scroll}
snippetGroup={snippetGroup}
{...componentProps}
/>
);
})}
</div>
);
}

+ 54
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/ComponentSourceSnippetViewer-test.tsx View File

@@ -118,6 +118,55 @@ it('should handle symbol highlighting', () => {
expect(wrapper.state('highlightedSymbols')).toEqual(['foo']);
});

it('should correctly handle lines actions', () => {
const snippetGroup: T.SnippetGroup = {
locations: [
mockFlowLocation({
component: 'a',
textRange: { startLine: 34, endLine: 34, startOffset: 0, endOffset: 0 }
}),
mockFlowLocation({
component: 'a',
textRange: { startLine: 54, endLine: 54, startOffset: 0, endOffset: 0 }
})
],
...mockSnippetsByComponent('a', [32, 33, 34, 35, 36, 52, 53, 54, 55, 56])
};
const loadDuplications = jest.fn();
const onLinePopupToggle = jest.fn();
const renderDuplicationPopup = jest.fn();

const wrapper = shallowRender({
loadDuplications,
onLinePopupToggle,
renderDuplicationPopup,
snippetGroup
});

const line = mockSourceLine();
wrapper
.find('Line')
.first()
.prop<Function>('loadDuplications')(line);
expect(loadDuplications).toHaveBeenCalledWith('a', line);

wrapper
.find('Line')
.first()
.prop<Function>('onLinePopupToggle')({ line: 13, name: 'foo' });
expect(onLinePopupToggle).toHaveBeenCalledWith({ component: 'a', line: 13, name: 'foo' });

wrapper
.find('Line')
.first()
.prop<Function>('renderDuplicationPopup')(1, 13);
expect(renderDuplicationPopup).toHaveBeenCalledWith(
mockSourceViewerFile({ key: 'a', path: 'a' }),
1,
13
);
});

function shallowRender(props: Partial<ComponentSourceSnippetViewer['props']> = {}) {
const snippetGroup: T.SnippetGroup = {
component: mockSourceViewerFile(),
@@ -127,13 +176,18 @@ function shallowRender(props: Partial<ComponentSourceSnippetViewer['props']> = {
return shallow<ComponentSourceSnippetViewer>(
<ComponentSourceSnippetViewer
branchLike={mockMainBranch()}
duplications={undefined}
duplicationsByLine={undefined}
highlightedLocationMessage={{ index: 0, text: '' }}
issue={mockIssue()}
issuesByLine={{}}
last={false}
linePopup={undefined}
loadDuplications={jest.fn()}
locations={[]}
onIssueChange={jest.fn()}
onIssuePopupToggle={jest.fn()}
onLinePopupToggle={jest.fn()}
onLocationSelect={jest.fn()}
renderDuplicationPopup={jest.fn()}
scroll={jest.fn()}

+ 75
- 10
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/CrossComponentSourceViewerWrapper-test.tsx View File

@@ -20,31 +20,46 @@
import * as React from 'react';
import { shallow } from 'enzyme';
import CrossComponentSourceViewerWrapper from '../CrossComponentSourceViewerWrapper';
import { mockIssue, mockSourceViewerFile } from '../../../../helpers/testMocks';
import {
mockFlowLocation,
mockIssue,
mockSnippetsByComponent,
mockSourceLine,
mockSourceViewerFile
} from '../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../helpers/testUtils';
import { getIssueFlowSnippets } from '../../../../api/issues';
import { getDuplications } from '../../../../api/components';

jest.mock('../../../../api/issues', () => {
const { mockSourceViewerFile } = require.requireActual('../../../../helpers/testMocks');
const { mockSnippetsByComponent } = require.requireActual('../../../../helpers/testMocks');
return {
getIssueFlowSnippets: jest.fn().mockResolvedValue([mockSourceViewerFile()])
getIssueFlowSnippets: jest.fn().mockResolvedValue({ 'main.js': mockSnippetsByComponent() })
};
});

jest.mock('../../../../api/components', () => ({
getDuplications: jest.fn().mockResolvedValue({})
}));

beforeEach(() => {
jest.clearAllMocks();
});

it('should render correctly', () => {
expect(shallowRender()).toMatchSnapshot();
it('should render correctly', async () => {
const wrapper = shallowRender();
expect(wrapper).toMatchSnapshot();

await waitAndUpdate(wrapper);
expect(wrapper).toMatchSnapshot();
});

it('Should fetch data', async () => {
const wrapper = shallowRender();
wrapper.instance().fetchIssueFlowSnippets('124');
await waitAndUpdate(wrapper);
expect(getIssueFlowSnippets).toBeCalled();
expect(wrapper.state('components')).toEqual([mockSourceViewerFile()]);
expect(getIssueFlowSnippets).toHaveBeenCalledWith('1');
expect(wrapper.state('components')).toEqual({ 'main.js': mockSnippetsByComponent() });

(getIssueFlowSnippets as jest.Mock).mockClear();
wrapper.setProps({ issue: mockIssue(true, { key: 'foo' }) });
@@ -62,18 +77,68 @@ it('should handle issue popup', () => {
expect(wrapper.state('issuePopup')).toBeUndefined();
});

it('should handle line popup', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);

const linePopup = { component: 'foo', index: 0, line: 16, name: 'b.tsx' };
wrapper.find('ComponentSourceSnippetViewer').prop<Function>('onLinePopupToggle')(linePopup);
expect(wrapper.state('linePopup')).toEqual(linePopup);

wrapper.find('ComponentSourceSnippetViewer').prop<Function>('onLinePopupToggle')(linePopup);
expect(wrapper.state('linePopup')).toEqual(undefined);

const openLinePopup = { ...linePopup, open: true };
wrapper.find('ComponentSourceSnippetViewer').prop<Function>('onLinePopupToggle')(openLinePopup);
wrapper.find('ComponentSourceSnippetViewer').prop<Function>('onLinePopupToggle')(openLinePopup);
expect(wrapper.state('linePopup')).toEqual(linePopup);
});

it('should handle duplication popup', async () => {
const files = { b: { key: 'b', name: 'B.tsx', project: 'foo', projectName: 'Foo' } };
const duplications = [{ blocks: [{ _ref: '1', from: 1, size: 2 }] }];
(getDuplications as jest.Mock).mockResolvedValueOnce({ duplications, files });

const wrapper = shallowRender();
await waitAndUpdate(wrapper);

wrapper.find('ComponentSourceSnippetViewer').prop<Function>('loadDuplications')(
'foo',
mockSourceLine()
);

await waitAndUpdate(wrapper);
expect(getDuplications).toHaveBeenCalledWith({ key: 'foo' });
expect(wrapper.state('duplicatedFiles')).toEqual(files);
expect(wrapper.state('duplications')).toEqual(duplications);
expect(wrapper.state('duplicationsByLine')).toEqual({ '1': [0], '2': [0] });
expect(wrapper.state('linePopup')).toEqual({
component: 'foo',
index: 0,
line: 16,
name: 'duplications'
});

expect(
wrapper.find('ComponentSourceSnippetViewer').prop<Function>('renderDuplicationPopup')(
mockSourceViewerFile(),
0,
16
)
).toMatchSnapshot();
});

function shallowRender(props: Partial<CrossComponentSourceViewerWrapper['props']> = {}) {
return shallow<CrossComponentSourceViewerWrapper>(
<CrossComponentSourceViewerWrapper
branchLike={undefined}
highlightedLocationMessage={undefined}
issue={mockIssue(true)}
issue={mockIssue(true, { key: '1' })}
issues={[]}
locations={[]}
locations={[mockFlowLocation()]}
onIssueChange={jest.fn()}
onLoaded={jest.fn()}
onLocationSelect={jest.fn()}
renderDuplicationPopup={jest.fn()}
scroll={jest.fn()}
selectedFlowIndex={0}
{...props}

+ 182
- 0
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/CrossComponentSourceViewerWrapper-test.tsx.snap View File

@@ -1,5 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`should handle duplication popup 1`] = `
<Context.Consumer>
[Function]
</Context.Consumer>
`;

exports[`should render correctly 1`] = `
<div>
<DeferredSpinner
@@ -7,3 +13,179 @@ exports[`should render correctly 1`] = `
/>
</div>
`;

exports[`should render correctly 2`] = `
<div>
<ComponentSourceSnippetViewer
issue={
Object {
"actions": Array [],
"component": "main.js",
"componentLongName": "main.js",
"componentQualifier": "FIL",
"componentUuid": "foo1234",
"creationDate": "2017-03-01T09:36:01+0100",
"flows": Array [
Array [
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
],
Array [
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
],
],
"fromHotspot": false,
"key": "1",
"line": 25,
"message": "Reduce the number of conditional operators (4) used in the expression",
"organization": "myorg",
"project": "myproject",
"projectKey": "foo",
"projectName": "Foo",
"projectOrganization": "org",
"rule": "javascript:S1067",
"ruleName": "foo",
"secondaryLocations": Array [
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
Object {
"component": "main.js",
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
],
"severity": "MAJOR",
"status": "OPEN",
"textRange": Object {
"endLine": 26,
"endOffset": 15,
"startLine": 25,
"startOffset": 0,
},
"transitions": Array [],
"type": "BUG",
}
}
issuesByLine={Object {}}
key="1-0-0"
last={true}
loadDuplications={[Function]}
locations={
Array [
Object {
"component": "main.js",
"index": 0,
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
]
}
onIssueChange={[MockFunction]}
onIssuePopupToggle={[Function]}
onLinePopupToggle={[Function]}
onLocationSelect={[MockFunction]}
renderDuplicationPopup={[Function]}
scroll={[MockFunction]}
snippetGroup={
Object {
"component": Object {
"key": "main.js",
"measures": Object {
"coverage": "85.2",
"duplicationDensity": "1.0",
"issues": "12",
"lines": "56",
},
"path": "main.js",
"project": "my-project",
"projectName": "MyProject",
"q": "FIL",
"uuid": "foo-bar",
},
"locations": Array [
Object {
"component": "main.js",
"index": 0,
"textRange": Object {
"endLine": 2,
"endOffset": 2,
"startLine": 1,
"startOffset": 1,
},
},
],
"sources": Object {
"16": Object {
"code": "<span class=\\"k\\">import</span> java.util.<span class=\\"sym-9 sym\\">ArrayList</span>;",
"coverageStatus": "covered",
"coveredConditions": 2,
"duplicated": false,
"isNew": true,
"line": 16,
"scmAuthor": "simon.brandhof@sonarsource.com",
"scmDate": "2018-12-11T10:48:39+0100",
"scmRevision": "80f564becc0c0a1c9abaa006eca83a4fd278c3f0",
},
},
}
}
/>
</div>
`;

+ 4
- 1
server/sonar-web/src/main/js/apps/issues/styles.css View File

@@ -254,8 +254,11 @@
text-align: left;
cursor: pointer;
}
.snippet > .expand-block:hover {
.snippet > .expand-block:hover,
.snippet > .expand-block:focus,
.snippet > .expand-block:active {
color: var(--darkBlue);
outline: none;
}
.snippet > .expand-block-above {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII=');

+ 15
- 34
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx View File

@@ -27,12 +27,18 @@ import { SourceViewerContext } from './SourceViewerContext';
import DuplicationPopup from './components/DuplicationPopup';
import defaultLoadIssues from './helpers/loadIssues';
import getCoverageStatus from './helpers/getCoverageStatus';
import {
filterDuplicationBlocksByLine,
getDuplicationBlocksForIndex,
isDuplicationBlockInRemovedComponent
} from './helpers/duplications';
import {
duplicationsByLine,
issuesByLine,
locationsByLine,
symbolsByLine
} from './helpers/indexing';
import { Alert } from '../ui/Alert';
import {
getComponentData,
getComponentForSourceViewer,
@@ -41,7 +47,6 @@ import {
} from '../../api/components';
import { isSameBranchLike, getBranchLikeQuery } from '../../helpers/branches';
import { translate } from '../../helpers/l10n';
import { Alert } from '../ui/Alert';
import { WorkspaceContext } from '../workspace/context';
import './styles.css';

@@ -97,7 +102,7 @@ interface State {
issuePopup?: { issue: string; name: string };
issues?: T.Issue[];
issuesByLine: { [line: number]: T.Issue[] };
linePopup?: { index?: number; line: number; name: string };
linePopup?: T.LinePopup;
loading: boolean;
loadingSourcesAfter: boolean;
loadingSourcesBefore: boolean;
@@ -495,17 +500,7 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
);
};

handleLinePopupToggle = ({
index,
line,
name,
open
}: {
index?: number;
line: number;
name: string;
open?: boolean;
}) => {
handleLinePopupToggle = ({ index, line, name, open }: T.LinePopup) => {
this.setState((state: State) => {
const samePopup =
state.linePopup !== undefined &&
@@ -587,34 +582,20 @@ export default class SourceViewerBase extends React.PureComponent<Props, State>
renderDuplicationPopup = (index: number, line: number) => {
const { component, duplicatedFiles, duplications } = this.state;

if (!component || !duplicatedFiles) return <></>;

const duplication = duplications && duplications[index];
let blocks = (duplication && duplication.blocks) || [];
/* eslint-disable no-underscore-dangle */
const inRemovedComponent = blocks.some(b => b._ref === undefined);
let foundOne = false;
blocks = blocks.filter(b => {
const outOfBounds = b.from > line || b.from + b.size < line;
const currentFile = b._ref === '1';
const shouldDisplayForCurrentFile = outOfBounds || foundOne;
const shouldDisplay = !currentFile || shouldDisplayForCurrentFile;
const isOk = b._ref !== undefined && shouldDisplay;
if (b._ref === '1' && !outOfBounds) {
foundOne = true;
}
return isOk;
});
/* eslint-enable no-underscore-dangle */
if (!component || !duplicatedFiles) {
return null;
}

const blocks = getDuplicationBlocksForIndex(duplications, index);

return (
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<DuplicationPopup
blocks={blocks}
blocks={filterDuplicationBlocksByLine(blocks, line)}
branchLike={this.props.branchLike}
duplicatedFiles={duplicatedFiles}
inRemovedComponent={inRemovedComponent}
inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)}
onClose={this.closeLinePopup}
openComponent={openComponent}
sourceViewerFile={component}

+ 3
- 3
server/sonar-web/src/main/js/components/SourceViewer/SourceViewerCode.tsx View File

@@ -56,7 +56,7 @@ interface Props {
issuePopup: { issue: string; name: string } | undefined;
issues: T.Issue[] | undefined;
issuesByLine: { [line: number]: T.Issue[] };
linePopup: { index?: number; line: number; name: string } | undefined;
linePopup: T.LinePopup | undefined;
loadDuplications: (line: T.SourceLine) => void;
loadingSourcesAfter: boolean;
loadingSourcesBefore: boolean;
@@ -68,11 +68,11 @@ interface Props {
onIssueSelect: (issueKey: string) => void;
onIssuesOpen: (line: T.SourceLine) => void;
onIssueUnselect: () => void;
onLinePopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void;
onLinePopupToggle: (linePopup: T.LinePopup) => void;
onLocationSelect: ((index: number) => void) | undefined;
onSymbolClick: (symbols: string[]) => void;
openIssuesByLine: { [line: number]: boolean };
renderDuplicationPopup: (index: number, line: number) => JSX.Element;
renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
scroll?: (element: HTMLElement) => void;
selectedIssue: string | undefined;
sources: T.SourceLine[];

+ 3
- 3
server/sonar-web/src/main/js/components/SourceViewer/components/Line.tsx View File

@@ -46,9 +46,9 @@ interface Props {
issues: T.Issue[];
last: boolean;
line: T.SourceLine;
linePopup: { index?: number; line: number; name: string } | undefined;
linePopup: T.LinePopup | undefined;
loadDuplications: (line: T.SourceLine) => void;
onLinePopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void;
onLinePopupToggle: (linePopup: T.LinePopup) => void;
onIssueChange: (issue: T.Issue) => void;
onIssuePopupToggle: (issueKey: string, popupName: string, open?: boolean) => void;
onIssuesClose: (line: T.SourceLine) => void;
@@ -59,7 +59,7 @@ interface Props {
onSymbolClick: (symbols: string[]) => void;
openIssues: boolean;
previousLine: T.SourceLine | undefined;
renderDuplicationPopup: (index: number, line: number) => JSX.Element;
renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
scroll?: (element: HTMLElement) => void;
secondaryIssueLocations: T.LinearIssueLocation[];
selectedIssue: string | undefined;

+ 2
- 2
server/sonar-web/src/main/js/components/SourceViewer/components/LineDuplicationBlock.tsx View File

@@ -27,9 +27,9 @@ interface Props {
duplicated: boolean;
index: number;
line: T.SourceLine;
onPopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void;
onPopupToggle: (linePopup: T.LinePopup) => void;
popupOpen: boolean;
renderDuplicationPopup: (index: number, line: number) => JSX.Element;
renderDuplicationPopup: (index: number, line: number) => React.ReactNode;
}

export default class LineDuplicationBlock extends React.PureComponent<Props> {

+ 1
- 1
server/sonar-web/src/main/js/components/SourceViewer/components/LineNumber.tsx View File

@@ -23,7 +23,7 @@ import Toggler from '../../controls/Toggler';

interface Props {
line: T.SourceLine;
onPopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void;
onPopupToggle: (linePopup: T.LinePopup) => void;
popupOpen: boolean;
}


+ 1
- 1
server/sonar-web/src/main/js/components/SourceViewer/components/LineSCM.tsx View File

@@ -23,7 +23,7 @@ import Toggler from '../../controls/Toggler';

interface Props {
line: T.SourceLine;
onPopupToggle: (x: { index?: number; line: number; name: string; open?: boolean }) => void;
onPopupToggle: (linePopup: T.LinePopup) => void;
popupOpen: boolean;
previousLine: T.SourceLine | undefined;
}

+ 66
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/__snapshots__/loadIssues-test.ts.snap View File

@@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`loadIssues should load issues 1`] = `
Array [
Object {
"actions": Array [
"set_tags",
"comment",
"assign",
],
"assignee": "luke",
"assigneeActive": true,
"assigneeAvatar": "lukavatar",
"assigneeLogin": "luke",
"assigneeName": "Luke",
"author": "luke@sonarsource.com",
"comments": Array [],
"component": "foo.java",
"componentEnabled": true,
"componentKey": "foo.java",
"componentLongName": "Foo.java",
"componentName": "foo.java",
"componentOrganization": "default-organization",
"componentPath": "/foo.java",
"componentQualifier": "FIL",
"creationDate": "2016-08-15T15:25:38+0200",
"flows": Array [],
"fromHotspot": true,
"hash": "78417dcee7ba927b7e7c9161e29e02b8",
"key": "AWaqVGl3tut9VbnJvk6M",
"line": 62,
"message": "Make sure this file handling is safe here.",
"organization": "default-organization",
"project": "org.sonarsource.java:java",
"projectEnabled": true,
"projectKey": "org.sonarsource.java:java",
"projectLongName": "SonarJava",
"projectName": "SonarJava",
"projectOrganization": "default-organization",
"projectQualifier": "TRK",
"rule": "squid:S4797",
"ruleKey": "squid:S4797",
"ruleLang": "java",
"ruleLangName": "Java",
"ruleName": "Handling files is security-sensitive",
"ruleStatus": "READY",
"secondaryLocations": Array [],
"status": "OPEN",
"tags": Array [
"cert",
"cwe",
"owasp-a1",
"owasp-a3",
],
"textRange": Object {
"endLine": 62,
"endOffset": 96,
"startLine": 62,
"startOffset": 93,
},
"transitions": Array [],
"type": "SECURITY_HOTSPOT",
"updateDate": "2018-10-25T10:23:08+0200",
},
]
`;

+ 49
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/duplications-test.ts View File

@@ -0,0 +1,49 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 {
getDuplicationBlocksForIndex,
isDuplicationBlockInRemovedComponent
} from '../duplications';

describe('getDuplicationBlocksForIndex', () => {
it('should return duplications blocks', () => {
const blocks = [{ _ref: '0', from: 2, size: 2 }];
expect(getDuplicationBlocksForIndex([{ blocks }], 0)).toBe(blocks);
expect(getDuplicationBlocksForIndex([{ blocks }], 5)).toEqual([]);
expect(getDuplicationBlocksForIndex(undefined, 5)).toEqual([]);
});
});

describe('isDuplicationBlockInRemovedComponent', () => {
it('should ', () => {
expect(
isDuplicationBlockInRemovedComponent([
{ _ref: '0', from: 2, size: 2 },
{ _ref: '0', from: 3, size: 1 }
])
).toBe(false);
expect(
isDuplicationBlockInRemovedComponent([
{ _ref: undefined, from: 2, size: 2 },
{ _ref: '0', from: 3, size: 1 }
])
).toBe(true);
});
});

+ 92
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/__tests__/loadIssues-test.ts View File

@@ -0,0 +1,92 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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 loadIssues from '../loadIssues';
import { mockMainBranch } from '../../../../helpers/testMocks';

jest.mock('../../../../api/issues', () => ({
searchIssues: jest.fn().mockResolvedValue({
paging: { pageIndex: 1, pageSize: 500, total: 1 },
effortTotal: 15,
debtTotal: 15,
issues: [
{
key: 'AWaqVGl3tut9VbnJvk6M',
rule: 'squid:S4797',
component: 'foo.java',
project: 'org.sonarsource.java:java',
line: 62,
hash: '78417dcee7ba927b7e7c9161e29e02b8',
textRange: { startLine: 62, endLine: 62, startOffset: 93, endOffset: 96 },
flows: [],
status: 'OPEN',
message: 'Make sure this file handling is safe here.',
assignee: 'luke',
author: 'luke@sonarsource.com',
tags: ['cert', 'cwe', 'owasp-a1', 'owasp-a3'],
transitions: [],
actions: ['set_tags', 'comment', 'assign'],
comments: [],
creationDate: '2016-08-15T15:25:38+0200',
updateDate: '2018-10-25T10:23:08+0200',
type: 'SECURITY_HOTSPOT',
organization: 'default-organization',
fromHotspot: true
}
],
components: [
{
organization: 'default-organization',
key: 'org.sonarsource.java:java',
enabled: true,
qualifier: 'TRK',
name: 'SonarJava',
longName: 'SonarJava'
},
{
organization: 'default-organization',
key: 'foo.java',
enabled: true,
qualifier: 'FIL',
name: 'foo.java',
longName: 'Foo.java',
path: '/foo.java'
}
],
rules: [
{
key: 'squid:S4797',
name: 'Handling files is security-sensitive',
lang: 'java',
status: 'READY',
langName: 'Java'
}
],
users: [{ login: 'luke', name: 'Luke', avatar: 'lukavatar', active: true }],
languages: [{ key: 'java', name: 'Java' }],
facets: []
})
}));

describe('loadIssues', () => {
it('should load issues', async () => {
const result = await loadIssues('foo.java', 1, 500, mockMainBranch());
expect(result).toMatchSnapshot();
});
});

+ 48
- 0
server/sonar-web/src/main/js/components/SourceViewer/helpers/duplications.ts View File

@@ -0,0 +1,48 @@
/*
* SonarQube
* Copyright (C) 2009-2019 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.
*/

// TODO Test this function, but I don't get the logic behind it
export function filterDuplicationBlocksByLine(blocks: T.DuplicationBlock[], line: number) {
/* eslint-disable no-underscore-dangle */
let foundOne = false;
return blocks.filter(b => {
const outOfBounds = b.from > line || b.from + b.size < line;
const currentFile = b._ref === '1';
const shouldDisplayForCurrentFile = outOfBounds || foundOne;
const shouldDisplay = !currentFile || shouldDisplayForCurrentFile;
const isOk = b._ref !== undefined && shouldDisplay;
if (b._ref === '1' && !outOfBounds) {
foundOne = true;
}
return isOk;
});
/* eslint-enable no-underscore-dangle */
}

export function getDuplicationBlocksForIndex(
duplications: T.Duplication[] | undefined,
index: number
) {
return (duplications && duplications[index] && duplications[index].blocks) || [];
}

export function isDuplicationBlockInRemovedComponent(blocks: T.DuplicationBlock[]) {
return blocks.some(b => b._ref === undefined); // eslint-disable-line no-underscore-dangle
}

server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.tsx → server/sonar-web/src/main/js/components/SourceViewer/helpers/getCoverageStatus.ts View File


server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.tsx → server/sonar-web/src/main/js/components/SourceViewer/helpers/loadIssues.ts View File


+ 1
- 0
server/sonar-web/src/main/js/components/issue/Issue.css View File

@@ -31,6 +31,7 @@

.issue.selected {
box-shadow: none;
outline: none;
border: 2px solid var(--blue) !important;
}


Loading…
Cancel
Save