Browse Source

SONAR-12022 Animate snippets

tags/8.0
Jeremy Davis 5 years ago
parent
commit
437a3a305e

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

@@ -792,6 +792,13 @@ declare namespace T {
type: 'SHORT';
}

export interface Snippet {
start: number;
end: number;
index: number;
toDelete?: boolean;
}

export interface SnippetGroup extends SnippetsByComponent {
locations: T.FlowLocation[];
}

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

@@ -19,7 +19,13 @@
*/
import * as React from 'react';
import * as classNames from 'classnames';
import { createSnippets, expandSnippet, EXPAND_BY_LINES, MERGE_DISTANCE } from './utils';
import {
createSnippets,
expandSnippet,
EXPAND_BY_LINES,
MERGE_DISTANCE,
linesForSnippets
} from './utils';
import SnippetViewer from './SnippetViewer';
import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
@@ -57,11 +63,12 @@ interface State {
highlightedSymbols: string[];
loading: boolean;
openIssuesByLine: T.Dict<boolean>;
snippets: T.SourceLine[][];
snippets: T.Snippet[];
}

export default class ComponentSourceSnippetViewer extends React.PureComponent<Props, State> {
mounted = false;
rootNodeRef = React.createRef<HTMLDivElement>();
state: State = {
additionalLines: {},
highlightedSymbols: [],
@@ -80,35 +87,78 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
}

createSnippetsFromProps() {
const snippets = createSnippets(
this.props.snippetGroup.locations,
this.props.snippetGroup.sources,
this.props.last
);
const snippets = createSnippets(this.props.snippetGroup.locations, this.props.last);
this.setState({ snippets });
}

getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
const root = this.rootNodeRef.current;
if (!root) {
return undefined;
}
const element = root.querySelector(`#snippet-wrapper-${index}`);
if (!element) {
return undefined;
}
const wrapper = element.querySelector<HTMLElement>('.snippet');
if (!wrapper) {
return undefined;
}
const table = wrapper.firstChild as HTMLElement;
if (!table) {
return undefined;
}

return { wrapper, table };
}

setMaxHeight(index: number, value?: number, up = false) {
const nodes = this.getNodes(index);

if (!nodes) {
return;
}

const { wrapper, table } = nodes;

const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;

if (up) {
const startHeight = wrapper.getBoundingClientRect().height;
table.style.transition = 'none';
table.style.marginTop = `${startHeight - maxHeight}px`;

// animate!
setTimeout(() => {
table.style.transition = '';
table.style.marginTop = '0px';
wrapper.style.maxHeight = `${maxHeight + 20}px`;
}, 0);
} else {
wrapper.style.maxHeight = `${maxHeight + 20}px`;
}
}

expandBlock = (snippetIndex: number, direction: T.ExpandDirection) => {
const { branchLike, snippetGroup } = this.props;
const { key } = snippetGroup.component;
const { snippets } = this.state;

const snippet = snippets[snippetIndex];

const snippet = snippets.find(s => s.index === snippetIndex);
if (!snippet) {
return;
}
// Extend by EXPAND_BY_LINES and add buffer for merging snippets
const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;

const range =
direction === 'up'
? {
from: Math.max(1, snippet[0].line - extension),
to: snippet[0].line - 1
from: Math.max(1, snippet.start - extension),
to: snippet.start - 1
}
: {
from: snippet[snippet.length - 1].line + 1,
to: snippet[snippet.length - 1].line + extension
from: snippet.end + 1,
to: snippet.end + extension
};

getSources({
key,
...range,
@@ -122,27 +172,55 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
}, {})
)
.then(
newLinesMapped => {
if (this.mounted) {
this.setState(({ additionalLines, snippets }) => {
const combinedLines = { ...additionalLines, ...newLinesMapped };

return {
additionalLines: combinedLines,
snippets: expandSnippet({
direction,
lines: { ...combinedLines, ...this.props.snippetGroup.sources },
snippetIndex,
snippets
})
};
});
}
},
newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped),
() => {}
);
};

animateBlockExpansion(
snippetIndex: number,
direction: T.ExpandDirection,
newLinesMapped: T.Dict<T.SourceLine>
) {
if (this.mounted) {
const { snippets } = this.state;

const newSnippets = expandSnippet({
direction,
snippetIndex,
snippets
});

const deletedSnippets = newSnippets.filter(s => s.toDelete);

// set max-height to current height for CSS transitions
deletedSnippets.forEach(s => this.setMaxHeight(s.index));
this.setMaxHeight(snippetIndex);

this.setState(
({ additionalLines, snippets }) => {
const combinedLines = { ...additionalLines, ...newLinesMapped };
return {
additionalLines: combinedLines,
snippets
};
},
() => {
// Set max-height 0 to trigger CSS transitions
deletedSnippets.forEach(s => {
this.setMaxHeight(s.index, 0);
});
this.setMaxHeight(snippetIndex, undefined, direction === 'up');

// Wait for transition to finish before updating dom
setTimeout(() => {
this.setState({ snippets: newSnippets.filter(s => !s.toDelete) });
}, 200);
}
);
}
}

expandComponent = () => {
const { branchLike, snippetGroup } = this.props;
const { key } = snippetGroup.component;
@@ -152,7 +230,14 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
lines => {
if (this.mounted) {
this.setState({ loading: false, snippets: [lines] });
this.setState(({ additionalLines }) => {
const combinedLines = { ...additionalLines, ...lines };
return {
additionalLines: combinedLines,
loading: false,
snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
};
});
}
},
() => {
@@ -222,7 +307,6 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
issue={this.props.issue}
issuePopup={this.props.issuePopup}
issuesByLine={issuesByLine}
key={index}
last={last}
loadDuplications={this.loadDuplications}
locations={this.props.locations}
@@ -240,19 +324,26 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr

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

const fullyShown =
snippets.length === 1 &&
snippetGroup.component.measures &&
snippets[0].length === parseInt(snippetGroup.component.measures.lines || '', 10);
snippets[0].end - snippets[0].start ===
parseInt(snippetGroup.component.measures.lines || '', 10);

const snippetLines = linesForSnippets(snippets, {
...snippetGroup.sources,
...additionalLines
});

return (
<div
className={classNames('component-source-container', {
'source-duplications-expanded': duplications && duplications.length > 0
})}>
})}
ref={this.rootNodeRef}>
<SourceViewerHeaderSlim
branchLike={branchLike}
expandable={!fullyShown}
@@ -260,15 +351,17 @@ export default class ComponentSourceSnippetViewer extends React.PureComponent<Pr
onExpand={this.expandComponent}
sourceViewerFile={snippetGroup.component}
/>
{snippets.map((snippet, index) =>
this.renderSnippet({
snippet,
index,
issuesByLine: last ? issuesByLine : {},
locationsByLine: last && index === snippets.length - 1 ? locations : {},
last: last && index === snippets.length - 1
})
)}
{snippetLines.map((snippet, index) => (
<div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
{this.renderSnippet({
snippet,
index: snippets[index].index,
issuesByLine: last ? issuesByLine : {},
locationsByLine: last && index === snippets.length - 1 ? locations : {},
last: last && index === snippets.length - 1
})}
</div>
))}
</div>
);
}

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

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import classNames from 'classnames';
import ExpandSnippetIcon from 'sonar-ui-common/components/icons/ExpandSnippetIcon';
import { scrollHorizontally } from 'sonar-ui-common/helpers/scrolling';
import { translate } from 'sonar-ui-common/helpers/l10n';
@@ -191,46 +192,48 @@ export default class SnippetViewer extends React.PureComponent<Props> {

return (
<div className="source-viewer-code snippet" ref={this.node}>
<table className="source-table">
<tbody>
{snippet[0].line > 1 && (
<tr className="expand-block expand-block-above">
<td colSpan={5}>
<button
aria-label={translate('source_viewer.expand_above')}
onClick={this.expandBlock('up')}
type="button">
<ExpandSnippetIcon />
</button>
</td>
</tr>
)}
{snippet.map((line, index) =>
this.renderLine({
displayDuplications,
index,
issuesForLine: issuesByLine[line.line] || [],
issueLocations: locationsByLine[line.line] || [],
line,
snippet,
symbols: symbols[line.line],
verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0
})
)}
{(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
<tr className="expand-block expand-block-below">
<td colSpan={5}>
<button
aria-label={translate('source_viewer.expand_below')}
onClick={this.expandBlock('down')}
type="button">
<ExpandSnippetIcon />
</button>
</td>
</tr>
)}
</tbody>
</table>
<div>
{snippet[0].line > 1 && (
<div className="expand-block expand-block-above">
<button
aria-label={translate('source_viewer.expand_above')}
onClick={this.expandBlock('up')}
type="button">
<ExpandSnippetIcon />
</button>
</div>
)}
<table
className={classNames('source-table', {
'expand-up': snippet[0].line > 1,
'expand-down': !lastLine || snippet[snippet.length - 1].line < lastLine
})}>
<tbody>
{snippet.map((line, index) =>
this.renderLine({
displayDuplications,
index,
issuesForLine: issuesByLine[line.line] || [],
issueLocations: locationsByLine[line.line] || [],
line,
snippet,
symbols: symbols[line.line],
verticalBuffer: index === snippet.length - 1 ? verticalBuffer : 0
})
)}
</tbody>
</table>
{(!lastLine || snippet[snippet.length - 1].line < lastLine) && (
<div className="expand-block expand-block-below">
<button
aria-label={translate('source_viewer.expand_below')}
onClick={this.expandBlock('down')}
type="button">
<ExpandSnippetIcon />
</button>
</div>
)}
</div>
</div>
);
}

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

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import { shallow, mount, ReactWrapper } from 'enzyme';
import { times } from 'lodash';
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
import ComponentSourceSnippetViewer from '../ComponentSourceSnippetViewer';
@@ -70,7 +70,7 @@ it('should expand block', async () => {

expect(getSources).toHaveBeenCalledWith({ from: 19, key: 'a', to: 31 });
expect(wrapper.state('snippets')).toHaveLength(2);
expect(wrapper.state('snippets')[0]).toHaveLength(15);
expect(wrapper.state('snippets')[0]).toEqual({ index: 0, start: 22, end: 36 });
expect(Object.keys(wrapper.state('additionalLines'))).toHaveLength(10);
});

@@ -99,7 +99,7 @@ it('should expand full component', async () => {

expect(getSources).toHaveBeenCalledWith({ key: 'a' });
expect(wrapper.state('snippets')).toHaveLength(1);
expect(wrapper.state('snippets')[0]).toHaveLength(14);
expect(wrapper.state('snippets')[0]).toEqual({ index: -1, start: 0, end: 13 });
});

it('should get the right branch when expanding', async () => {
@@ -190,6 +190,107 @@ it('should correctly handle lines actions', () => {
);
});

describe('getNodes', () => {
const snippetGroup: T.SnippetGroup = {
component: mockSourceViewerFile(),
locations: [],
sources: []
};
const wrapper = mount<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()}
snippetGroup={snippetGroup}
/>
);

it('should return undefined if any node is missing', async () => {
await waitAndUpdate(wrapper);
const rootNode = wrapper.instance().rootNodeRef;
mockDom(rootNode.current!);
expect(wrapper.instance().getNodes(0)).toBeUndefined();
expect(wrapper.instance().getNodes(1)).toBeUndefined();
expect(wrapper.instance().getNodes(2)).toBeUndefined();
});

it('should return elements if dom is correct', async () => {
await waitAndUpdate(wrapper);
const rootNode = wrapper.instance().rootNodeRef;
mockDom(rootNode.current!);
expect(wrapper.instance().getNodes(3)).not.toBeUndefined();
});
});

describe('getHeight', () => {
jest.useFakeTimers();

const snippetGroup: T.SnippetGroup = {
component: mockSourceViewerFile(),
locations: [],
sources: []
};
const wrapper = mount<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()}
snippetGroup={snippetGroup}
/>
);

it('should set maxHeight to current height', async () => {
await waitAndUpdate(wrapper);

const nodes = mockDomForSizes(wrapper, { wrapperHeight: 42, tableHeight: 68 });
wrapper.instance().setMaxHeight(0);

expect(nodes.wrapper.getAttribute('style')).toBe('max-height: 88px;');
expect(nodes.table.getAttribute('style')).toBeNull();
});

it('should set margin and then maxHeight for a nice upwards animation', async () => {
await waitAndUpdate(wrapper);

const nodes = mockDomForSizes(wrapper, { wrapperHeight: 42, tableHeight: 68 });
wrapper.instance().setMaxHeight(0, undefined, true);

expect(nodes.wrapper.getAttribute('style')).toBeNull();
expect(nodes.table.getAttribute('style')).toBe('transition: none; margin-top: -26px;');

jest.runAllTimers();

expect(nodes.wrapper.getAttribute('style')).toBe('max-height: 88px;');
expect(nodes.table.getAttribute('style')).toBe('margin-top: 0px;');
});
});

function shallowRender(props: Partial<ComponentSourceSnippetViewer['props']> = {}) {
const snippetGroup: T.SnippetGroup = {
component: mockSourceViewerFile(),
@@ -219,3 +320,47 @@ function shallowRender(props: Partial<ComponentSourceSnippetViewer['props']> = {
/>
);
}

function mockDom(refNode: HTMLDivElement) {
refNode.querySelector = jest.fn(query => {
const index = query.split('-').pop();

switch (index) {
case '0':
return null;
case '1':
return mount(<div />).getDOMNode();
case '2':
return mount(
<div>
<div className="snippet" />
</div>
).getDOMNode();
case '3':
return mount(
<div>
<div className="snippet">
<div />
</div>
</div>
).getDOMNode();
default:
return null;
}
});
}

function mockDomForSizes(
componentWrapper: ReactWrapper<{}, {}, ComponentSourceSnippetViewer>,
{ wrapperHeight = 0, tableHeight = 0 }
) {
const wrapper = mount(<div className="snippet" />).getDOMNode();
wrapper.getBoundingClientRect = jest.fn().mockReturnValue({ height: wrapperHeight });
const table = mount(<div />).getDOMNode();
table.getBoundingClientRect = jest.fn().mockReturnValue({ height: tableHeight });
componentWrapper.instance().getNodes = jest.fn().mockReturnValue({
wrapper,
table
});
return { wrapper, table };
}

+ 889
- 903
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/__snapshots__/SnippetViewer-test.tsx.snap
File diff suppressed because it is too large
View File


+ 20
- 37
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/__tests__/utils-test.ts View File

@@ -17,13 +17,8 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { keyBy, range } from 'lodash';
import { groupLocationsByComponent, createSnippets, expandSnippet } from '../utils';
import {
mockFlowLocation,
mockSnippetsByComponent,
mockSourceLine
} from '../../../../helpers/testMocks';
import { mockFlowLocation, mockSnippetsByComponent } from '../../../../helpers/testMocks';

describe('groupLocationsByComponent', () => {
it('should handle empty args', () => {
@@ -92,12 +87,11 @@ describe('createSnippets', () => {
textRange: { startLine: 19, startOffset: 2, endLine: 19, endOffset: 3 }
})
],
mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22]).sources,
false
);

expect(results).toHaveLength(1);
expect(results[0]).toHaveLength(8);
expect(results[0]).toEqual({ index: 0, start: 14, end: 21 });
});

it('should merge snippets correctly, even when not in sequence', () => {
@@ -113,13 +107,12 @@ describe('createSnippets', () => {
textRange: { startLine: 14, startOffset: 2, endLine: 14, endOffset: 3 }
})
],
mockSnippetsByComponent('', [12, 13, 14, 15, 16, 17, 18, 45, 46, 47, 48, 49]).sources,
false
);

expect(results).toHaveLength(2);
expect(results[0]).toHaveLength(7);
expect(results[1]).toHaveLength(5);
expect(results[0]).toEqual({ index: 0, start: 12, end: 18 });
expect(results[1]).toEqual({ index: 1, start: 45, end: 49 });
});

it('should merge three snippets together', () => {
@@ -138,56 +131,46 @@ describe('createSnippets', () => {
textRange: { startLine: 18, startOffset: 2, endLine: 18, endOffset: 3 }
})
],
mockSnippetsByComponent('', [14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 45, 46, 47, 48, 49])
.sources,
false
);

expect(results).toHaveLength(2);
expect(results[0]).toHaveLength(11);
expect(results[1]).toHaveLength(5);
expect(results[0]).toEqual({ index: 0, start: 14, end: 24 });
expect(results[1]).toEqual({ index: 1, start: 45, end: 49 });
});
});

describe('expandSnippet', () => {
it('should add lines above', () => {
const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line');
const snippets = [[lines[14], lines[15], lines[16], lines[17], lines[18]]];
const snippets = [{ start: 14, end: 18, index: 0 }];

const result = expandSnippet({ direction: 'up', lines, snippetIndex: 0, snippets });
const result = expandSnippet({ direction: 'up', snippetIndex: 0, snippets });

expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(15);
expect(result[0].map(l => l.line)).toEqual(range(4, 19));
expect(result[0]).toEqual({ start: 4, end: 18, index: 0 });
});

it('should add lines below', () => {
const lines = keyBy(range(4, 19).map(line => mockSourceLine({ line })), 'line');
const snippets = [[lines[4], lines[5], lines[6], lines[7], lines[8]]];
const snippets = [{ start: 4, end: 8, index: 0 }];

const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets });
const result = expandSnippet({ direction: 'down', snippetIndex: 0, snippets });

expect(result).toHaveLength(1);
expect(result[0].map(l => l.line)).toEqual(range(4, 19));
expect(result[0]).toEqual({ start: 4, end: 18, index: 0 });
});

it('should merge snippets if necessary', () => {
const lines = keyBy(
range(4, 23)
.concat(range(38, 43))
.map(line => mockSourceLine({ line })),
'line'
);
const snippets = [
[lines[4], lines[5], lines[6], lines[7], lines[8]],
[lines[38], lines[39], lines[40], lines[41], lines[42]],
[lines[17], lines[18], lines[19], lines[20], lines[21]]
{ index: 1, start: 4, end: 8 },
{ index: 2, start: 38, end: 42 },
{ index: 3, start: 17, end: 21 }
];

const result = expandSnippet({ direction: 'down', lines, snippetIndex: 0, snippets });
const result = expandSnippet({ direction: 'down', snippetIndex: 1, snippets });

expect(result).toHaveLength(2);
expect(result[0].map(l => l.line)).toEqual(range(4, 22));
expect(result[1].map(l => l.line)).toEqual(range(38, 43));
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ index: 1, start: 4, end: 21 });
expect(result[1]).toEqual({ index: 2, start: 38, end: 42 });
expect(result[2]).toEqual({ index: 3, start: 17, end: 21, toDelete: true });
});
});

+ 60
- 84
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/utils.ts View File

@@ -42,62 +42,53 @@ function collision([startA, endA]: number[], [startB, endB]: number[]) {
return !(startA > endB + MERGE_DISTANCE || endA < startB - MERGE_DISTANCE);
}

export function createSnippets(
locations: T.FlowLocation[],
componentLines: T.LineMap = {},
last: boolean
): T.SourceLine[][] {
return rangesToSnippets(
// For each location's range (2 above and 2 below), and then compare with other ranges
// to merge snippets that collide.
locations.reduce((snippets: Array<{ start: number; end: number }>, loc, index) => {
const startIndex = Math.max(1, loc.textRange.startLine - LINES_ABOVE);
const endIndex =
loc.textRange.endLine +
(last && index === locations.length - 1 ? LINES_BELOW_LAST : LINES_BELOW);

let firstCollision: { start: number; end: number } | undefined;

// Remove ranges that collide into the first collision
snippets = snippets.filter(snippet => {
if (collision([snippet.start, snippet.end], [startIndex, endIndex])) {
let keep = false;
// Check if we've already collided
if (!firstCollision) {
firstCollision = snippet;
keep = true;
}
// Merge with first collision:
firstCollision.start = Math.min(startIndex, snippet.start, firstCollision.start);
firstCollision.end = Math.max(endIndex, snippet.end, firstCollision.end);

// remove the range if it was not the first collision
return keep;
export function createSnippets(locations: T.FlowLocation[], last: boolean): T.Snippet[] {
// For each location's range (2 above and 2 below), and then compare with other ranges
// to merge snippets that collide.
return locations.reduce((snippets: T.Snippet[], loc, index) => {
const startIndex = Math.max(1, loc.textRange.startLine - LINES_ABOVE);
const endIndex =
loc.textRange.endLine +
(last && index === locations.length - 1 ? LINES_BELOW_LAST : LINES_BELOW);

let firstCollision: { start: number; end: number } | undefined;

// Remove ranges that collide into the first collision
snippets = snippets.filter(snippet => {
if (collision([snippet.start, snippet.end], [startIndex, endIndex])) {
let keep = false;
// Check if we've already collided
if (!firstCollision) {
firstCollision = snippet;
keep = true;
}
return true;
});
// Merge with first collision:
firstCollision.start = Math.min(startIndex, snippet.start, firstCollision.start);
firstCollision.end = Math.max(endIndex, snippet.end, firstCollision.end);

if (firstCollision === undefined) {
snippets.push({
start: startIndex,
end: endIndex
});
// remove the range if it was not the first collision
return keep;
}
return true;
});

if (firstCollision === undefined) {
snippets.push({
start: startIndex,
end: endIndex,
index
});
}

return snippets;
}, []),
componentLines
);
return snippets;
}, []);
}

function rangesToSnippets(
ranges: Array<{ start: number; end: number }>,
componentLines: T.LineMap
) {
return ranges
.map(range => {
export function linesForSnippets(snippets: T.Snippet[], componentLines: T.LineMap) {
return snippets
.map(snippet => {
const lines = [];
for (let i = range.start; i <= range.end; i++) {
for (let i = snippet.start; i <= snippet.end; i++) {
if (componentLines[i]) {
lines.push(componentLines[i]);
}
@@ -133,51 +124,36 @@ export function groupLocationsByComponent(

export function expandSnippet({
direction,
lines,
snippetIndex,
snippets
}: {
direction: T.ExpandDirection;
lines: T.LineMap;
snippetIndex: number;
snippets: T.SourceLine[][];
snippets: T.Snippet[];
}) {
const snippetToExpand = snippets[snippetIndex];

const snippetToExpandRange = {
start: Math.max(0, snippetToExpand[0].line - (direction === 'up' ? EXPAND_BY_LINES : 0)),
end:
snippetToExpand[snippetToExpand.length - 1].line +
(direction === 'down' ? EXPAND_BY_LINES : 0)
};
const snippetToExpand = snippets.find(s => s.index === snippetIndex);
if (!snippetToExpand) {
throw new Error(`Snippet ${snippetIndex} not found`);
}

snippetToExpand.start = Math.max(
0,
snippetToExpand.start - (direction === 'up' ? EXPAND_BY_LINES : 0)
);
snippetToExpand.end += direction === 'down' ? EXPAND_BY_LINES : 0;

const ranges: Array<{ start: number; end: number }> = [];

snippets.forEach((snippet, index: number) => {
const snippetRange = {
start: snippet[0].line,
end: snippet[snippet.length - 1].line
};

if (index === snippetIndex) {
// keep expanded snippet
ranges.push(snippetToExpandRange);
} else if (
collision(
[snippetRange.start, snippetRange.end],
[snippetToExpandRange.start, snippetToExpandRange.end]
)
) {
return snippets.map(snippet => {
if (snippet.index === snippetIndex) {
return snippetToExpand;
}
if (collision([snippet.start, snippet.end], [snippetToExpand.start, snippetToExpand.end])) {
// Merge with expanded snippet
snippetToExpandRange.start = Math.min(snippetRange.start, snippetToExpandRange.start);
snippetToExpandRange.end = Math.max(snippetRange.end, snippetToExpandRange.end);
} else {
// No collision, jsut keep the snippet
ranges.push(snippetRange);
snippetToExpand.start = Math.min(snippet.start, snippetToExpand.start);
snippetToExpand.end = Math.max(snippet.end, snippetToExpand.end);
snippet.toDelete = true;
}
return snippet;
});

return rangesToSnippets(ranges, lines);
}

export function inSnippet(line: number, snippet: T.SourceLine[]) {

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

@@ -242,13 +242,28 @@
margin: var(--gridSize);
border: 1px solid var(--gray80);
overflow-x: auto;
overflow-y: hidden;
transition: max-height 0.2s;
}

.snippet > table {
.snippet > div {
display: table;
width: 100%;
position: relative;
transition: margin-top 0.2s;
}

.snippet table {
width: 100%;
}

.expand-block {
position: absolute;
z-index: 2;
width: 100%;
}

.expand-block > td > button {
.expand-block > button {
background: transparent;
box-sizing: border-box;
color: var(--secondFontColor);
@@ -259,17 +274,27 @@
text-align: left;
cursor: pointer;
}
.expand-block > td > button:hover,
.expand-block > td > button:focus,
.expand-block > td > button:active {
.expand-block > button:hover,
.expand-block > button:focus,
.expand-block > button:active {
color: var(--darkBlue);
outline: none;
}
.expand-block-above {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAAXNSR0IArs4c6QAAADdJREFUCB1dzMEKADAIAlBd1v9/bcc2YgRjHh8qq2qTxCQzsX4wM6y30RARF3sy0Es1SIK7Y64OpCES1W69JS4AAAAASUVORK5CYII=');
top: 0;
}
.expand-block-below {
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4wQQBjQEQVd5jwAAADhJREFUCNddyTEKADEMA8GVA/7/Z+PGwUp1cGTaYe/tv5lxrLWoKj6SiMzkjZDEG7JtANt0N+ccLrB/KZxXTt7fAAAAAElFTkSuQmCC');
bottom: 0;
}

.source-table.expand-up {
margin-top: 20px;
}

.source-table.expand-down {
margin-bottom: 20px;
}

.issues-my-issues-filter {

Loading…
Cancel
Save