@@ -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[]; | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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 }; | |||
} |
@@ -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 }); | |||
}); | |||
}); |
@@ -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[]) { |
@@ -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 { |