3 * Copyright (C) 2009-2020 SonarSource SA
4 * mailto:info AT sonarsource DOT com
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU Lesser General Public
8 * License as published by the Free Software Foundation; either
9 * version 3 of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 * Lesser General Public License for more details.
16 * You should have received a copy of the GNU Lesser General Public License
17 * along with this program; if not, write to the Free Software Foundation,
18 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 import * as classNames from 'classnames';
21 import * as React from 'react';
22 import { getSources } from '../../../api/components';
23 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
24 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
25 import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
26 import { getBranchLikeQuery } from '../../../helpers/branch-like';
27 import { BranchLike } from '../../../types/branch-like';
28 import SnippetViewer from './SnippetViewer';
38 branchLike: BranchLike | undefined;
39 duplications?: T.Duplication[];
40 duplicationsByLine?: { [line: number]: number[] };
41 highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
43 issuePopup?: { issue: string; name: string };
44 issuesByLine: T.IssuesByLine;
45 lastSnippetGroup: boolean;
46 linePopup?: T.LinePopup;
47 loadDuplications: (component: string, line: T.SourceLine) => void;
48 locations: T.FlowLocation[];
49 onIssueChange: (issue: T.Issue) => void;
50 onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
51 onLinePopupToggle: (linePopup: T.LinePopup & { component: string }) => void;
52 onLocationSelect: (index: number) => void;
53 renderDuplicationPopup: (
54 component: T.SourceViewerFile,
58 scroll?: (element: HTMLElement, offset: number) => void;
59 snippetGroup: T.SnippetGroup;
63 additionalLines: { [line: number]: T.SourceLine };
64 highlightedSymbols: string[];
66 openIssuesByLine: T.Dict<boolean>;
67 snippets: T.Snippet[];
70 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
72 rootNodeRef = React.createRef<HTMLDivElement>();
75 highlightedSymbols: [],
83 this.createSnippetsFromProps();
86 componentWillUnmount() {
90 createSnippetsFromProps() {
91 const { issue, snippetGroup } = this.props;
93 const snippets = createSnippets({
94 component: snippetGroup.component.key,
96 locations: snippetGroup.locations
99 this.setState({ snippets });
102 getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
103 const root = this.rootNodeRef.current;
107 const element = root.querySelector(`#snippet-wrapper-${index}`);
111 const wrapper = element.querySelector<HTMLElement>('.snippet');
115 const table = wrapper.firstChild as HTMLElement;
120 return { wrapper, table };
124 * Clean after animation
126 cleanDom(index: number) {
127 const nodes = this.getNodes(index);
133 const { wrapper, table } = nodes;
135 table.style.marginTop = '';
136 wrapper.style.maxHeight = '';
139 setMaxHeight(index: number, value?: number, up = false) {
140 const nodes = this.getNodes(index);
146 const { wrapper, table } = nodes;
148 const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;
151 const startHeight = wrapper.getBoundingClientRect().height;
152 table.style.transition = 'none';
153 table.style.marginTop = `${startHeight - maxHeight}px`;
157 table.style.transition = '';
158 table.style.marginTop = '0px';
159 wrapper.style.maxHeight = `${maxHeight + 20}px`;
162 wrapper.style.maxHeight = `${maxHeight + 20}px`;
166 expandBlock = (snippetIndex: number, direction: T.ExpandDirection): Promise<void> => {
167 const { branchLike, snippetGroup } = this.props;
168 const { key } = snippetGroup.component;
169 const { snippets } = this.state;
170 const snippet = snippets.find(s => s.index === snippetIndex);
172 return Promise.reject();
174 // Extend by EXPAND_BY_LINES and add buffer for merging snippets
175 const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
179 from: Math.max(1, snippet.start - extension),
180 to: snippet.start - 1
183 from: snippet.end + 1,
184 to: snippet.end + extension
189 ...getBranchLikeQuery(branchLike)
192 lines.reduce((lineMap: T.Dict<T.SourceLine>, line) => {
193 line.coverageStatus = getCoverageStatus(line);
194 lineMap[line.line] = line;
198 .then(newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped));
201 animateBlockExpansion(
202 snippetIndex: number,
203 direction: T.ExpandDirection,
204 newLinesMapped: T.Dict<T.SourceLine>
207 const { snippets } = this.state;
209 const newSnippets = expandSnippet({
215 const deletedSnippets = newSnippets.filter(s => s.toDelete);
217 // set max-height to current height for CSS transitions
218 deletedSnippets.forEach(s => this.setMaxHeight(s.index));
219 this.setMaxHeight(snippetIndex);
221 return new Promise(resolve => {
223 ({ additionalLines, snippets }) => {
224 const combinedLines = { ...additionalLines, ...newLinesMapped };
226 additionalLines: combinedLines,
231 // Set max-height 0 to trigger CSS transitions
232 deletedSnippets.forEach(s => {
233 this.setMaxHeight(s.index, 0);
235 this.setMaxHeight(snippetIndex, undefined, direction === 'up');
237 // Wait for transition to finish before updating dom
239 this.setState({ snippets: newSnippets.filter(s => !s.toDelete) }, resolve);
240 this.cleanDom(snippetIndex);
246 return Promise.resolve();
249 expandComponent = () => {
250 const { branchLike, snippetGroup } = this.props;
251 const { key } = snippetGroup.component;
253 this.setState({ loading: true });
255 getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
258 this.setState(({ additionalLines }) => {
259 const combinedLines = { ...additionalLines, ...lines };
261 additionalLines: combinedLines,
263 snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
270 this.setState({ loading: false });
276 handleLinePopupToggle = (linePopup: T.LinePopup) => {
277 this.props.onLinePopupToggle({
279 component: this.props.snippetGroup.component.key
283 handleOpenIssues = (line: T.SourceLine) => {
284 this.setState(state => ({
285 openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true }
289 handleCloseIssues = (line: T.SourceLine) => {
290 this.setState(state => ({
291 openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false }
295 handleSymbolClick = (clickedSymbols: string[]) => {
296 this.setState(({ highlightedSymbols }) => {
297 const newHighlightedSymbols = clickedSymbols.filter(
298 symb => !highlightedSymbols.includes(symb)
300 return { highlightedSymbols: newHighlightedSymbols };
304 loadDuplications = (line: T.SourceLine) => {
305 this.props.loadDuplications(this.props.snippetGroup.component.key, line);
308 renderDuplicationPopup = (index: number, line: number) => {
309 return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
315 lastSnippetOfLastGroup,
320 issuesByLine: T.IssuesByLine;
321 lastSnippetOfLastGroup: boolean;
322 locationsByLine: { [line: number]: T.LinearIssueLocation[] };
323 snippet: T.SourceLine[];
327 branchLike={this.props.branchLike}
328 component={this.props.snippetGroup.component}
329 expandBlock={this.expandBlock}
330 handleCloseIssues={this.handleCloseIssues}
331 handleLinePopupToggle={this.handleLinePopupToggle}
332 handleOpenIssues={this.handleOpenIssues}
333 handleSymbolClick={this.handleSymbolClick}
334 highlightedLocationMessage={this.props.highlightedLocationMessage}
335 highlightedSymbols={this.state.highlightedSymbols}
337 issue={this.props.issue}
338 issuePopup={this.props.issuePopup}
339 issuesByLine={issuesByLine}
340 lastSnippetOfLastGroup={lastSnippetOfLastGroup}
341 linePopup={this.props.linePopup}
342 loadDuplications={this.loadDuplications}
343 locations={this.props.locations}
344 locationsByLine={locationsByLine}
345 onIssueChange={this.props.onIssueChange}
346 onIssuePopupToggle={this.props.onIssuePopupToggle}
347 onLocationSelect={this.props.onLocationSelect}
348 openIssuesByLine={this.state.openIssuesByLine}
349 renderDuplicationPopup={this.renderDuplicationPopup}
350 scroll={this.props.scroll}
365 const { additionalLines, loading, snippets } = this.state;
367 issue.component === snippetGroup.component.key ? locationsByLine([issue]) : {};
370 snippets.length === 1 &&
371 snippetGroup.component.measures &&
372 snippets[0].end - snippets[0].start ===
373 parseInt(snippetGroup.component.measures.lines || '', 10);
375 const snippetLines = linesForSnippets(snippets, {
376 ...snippetGroup.sources,
380 const isFlow = issue.secondaryLocations.length === 0;
381 const includeIssueLocation = (snippetIndex: number) =>
382 isFlow ? lastSnippetGroup && snippetIndex === snippets.length - 1 : snippetIndex === 0;
386 className={classNames('component-source-container', {
387 'source-duplications-expanded': duplications && duplications.length > 0
389 ref={this.rootNodeRef}>
390 <SourceViewerHeaderSlim
391 branchLike={branchLike}
392 expandable={!fullyShown}
394 onExpand={this.expandComponent}
395 sourceViewerFile={snippetGroup.component}
397 {snippetLines.map((snippet, index) => (
398 <div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
399 {this.renderSnippet({
401 index: snippets[index].index,
403 locationsByLine: includeIssueLocation(index) ? locations : {},
404 lastSnippetOfLastGroup: lastSnippetGroup && index === snippets.length - 1