3 * Copyright (C) 2009-2022 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 { noop } from 'lodash';
21 import * as React from 'react';
22 import { getSources } from '../../../api/components';
23 import Issue from '../../../components/issue/Issue';
24 import LineIssuesList from '../../../components/SourceViewer/components/LineIssuesList';
25 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
26 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
27 import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
28 import { getBranchLikeQuery } from '../../../helpers/branch-like';
29 import { BranchLike } from '../../../types/branch-like';
42 } from '../../../types/types';
43 import SnippetViewer from './SnippetViewer';
53 branchLike: BranchLike | undefined;
54 duplications?: Duplication[];
55 duplicationsByLine?: { [line: number]: number[] };
56 highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
57 isLastOccurenceOfPrimaryComponent: boolean;
59 issuePopup?: { issue: string; name: string };
60 issuesByLine: IssuesByLine;
61 lastSnippetGroup: boolean;
62 loadDuplications: (component: string, line: SourceLine) => void;
63 locations: FlowLocation[];
64 onIssueChange: (issue: TypeIssue) => void;
65 onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
66 onLocationSelect: (index: number) => void;
67 renderDuplicationPopup: (
68 component: SourceViewerFile,
72 scroll?: (element: HTMLElement, offset: number) => void;
73 snippetGroup: SnippetGroup;
77 additionalLines: { [line: number]: SourceLine };
78 highlightedSymbols: string[];
80 openIssuesByLine: Dict<boolean>;
84 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
86 rootNodeRef = React.createRef<HTMLDivElement>();
89 highlightedSymbols: [],
97 this.createSnippetsFromProps();
100 componentWillUnmount() {
101 this.mounted = false;
104 createSnippetsFromProps() {
105 const { issue, snippetGroup } = this.props;
107 const snippets = createSnippets({
108 component: snippetGroup.component.key,
110 locations: snippetGroup.locations
113 this.setState({ snippets });
116 getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
117 const root = this.rootNodeRef.current;
121 const element = root.querySelector(`#snippet-wrapper-${index}`);
125 const wrapper = element.querySelector<HTMLElement>('.snippet');
129 const table = wrapper.firstChild as HTMLElement;
134 return { wrapper, table };
138 * Clean after animation
140 cleanDom(index: number) {
141 const nodes = this.getNodes(index);
147 const { wrapper, table } = nodes;
149 table.style.marginTop = '';
150 wrapper.style.maxHeight = '';
153 setMaxHeight(index: number, value?: number, up = false) {
154 const nodes = this.getNodes(index);
160 const { wrapper, table } = nodes;
162 const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;
165 const startHeight = wrapper.getBoundingClientRect().height;
166 table.style.transition = 'none';
167 table.style.marginTop = `${startHeight - maxHeight}px`;
171 table.style.transition = '';
172 table.style.marginTop = '0px';
173 wrapper.style.maxHeight = `${maxHeight + 20}px`;
176 wrapper.style.maxHeight = `${maxHeight + 20}px`;
180 expandBlock = (snippetIndex: number, direction: ExpandDirection): Promise<void> => {
181 const { branchLike, snippetGroup } = this.props;
182 const { key } = snippetGroup.component;
183 const { snippets } = this.state;
184 const snippet = snippets.find(s => s.index === snippetIndex);
186 return Promise.reject();
188 // Extend by EXPAND_BY_LINES and add buffer for merging snippets
189 const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
193 from: Math.max(1, snippet.start - extension),
194 to: snippet.start - 1
197 from: snippet.end + 1,
198 to: snippet.end + extension
203 ...getBranchLikeQuery(branchLike)
206 lines.reduce((lineMap: Dict<SourceLine>, line) => {
207 line.coverageStatus = getCoverageStatus(line);
208 lineMap[line.line] = line;
212 .then(newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped));
215 animateBlockExpansion(
216 snippetIndex: number,
217 direction: ExpandDirection,
218 newLinesMapped: Dict<SourceLine>
221 const { snippets } = this.state;
223 const newSnippets = expandSnippet({
229 const deletedSnippets = newSnippets.filter(s => s.toDelete);
231 // set max-height to current height for CSS transitions
232 deletedSnippets.forEach(s => this.setMaxHeight(s.index));
233 this.setMaxHeight(snippetIndex);
235 return new Promise(resolve => {
237 ({ additionalLines, snippets }) => {
238 const combinedLines = { ...additionalLines, ...newLinesMapped };
240 additionalLines: combinedLines,
245 // Set max-height 0 to trigger CSS transitions
246 deletedSnippets.forEach(s => {
247 this.setMaxHeight(s.index, 0);
249 this.setMaxHeight(snippetIndex, undefined, direction === 'up');
251 // Wait for transition to finish before updating dom
253 this.setState({ snippets: newSnippets.filter(s => !s.toDelete) }, resolve);
254 this.cleanDom(snippetIndex);
260 return Promise.resolve();
263 expandComponent = () => {
264 const { branchLike, snippetGroup } = this.props;
265 const { key } = snippetGroup.component;
267 this.setState({ loading: true });
269 getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
272 this.setState(({ additionalLines }) => {
273 const combinedLines = { ...additionalLines, ...lines };
275 additionalLines: combinedLines,
277 snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
284 this.setState({ loading: false });
290 handleOpenIssues = (line: SourceLine) => {
291 this.setState(state => ({
292 openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true }
296 handleCloseIssues = (line: SourceLine) => {
297 this.setState(state => ({
298 openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false }
302 handleSymbolClick = (clickedSymbols: string[]) => {
303 this.setState(({ highlightedSymbols }) => {
304 const newHighlightedSymbols = clickedSymbols.filter(
305 symb => !highlightedSymbols.includes(symb)
307 return { highlightedSymbols: newHighlightedSymbols };
311 loadDuplications = (line: SourceLine) => {
312 this.props.loadDuplications(this.props.snippetGroup.component.key, line);
315 renderDuplicationPopup = (index: number, line: number) => {
316 return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
319 renderIssuesList = (line: SourceLine) => {
320 const { openIssuesByLine } = this.state;
322 const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
324 issue.component === snippetGroup.component.key && issue.textRange !== undefined
325 ? locationsByLine([issue])
328 const isFlow = issue.secondaryLocations.length === 0;
329 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
331 const issuesForLine = issuesByLine[line.line] || [];
333 const selectedIssue = issuesForLine.find(i => i.key === issue.key)?.key;
335 const issueLocationsByLine = includeIssueLocation ? locations : {};
338 issueLocationsByLine={issueLocationsByLine}
339 issuesForLine={issuesForLine}
341 openIssuesByLine={openIssuesByLine}
342 branchLike={this.props.branchLike}
343 issuePopup={this.props.issuePopup}
344 onIssueChange={this.props.onIssueChange}
346 onIssuePopupToggle={this.props.onIssuePopupToggle}
347 selectedIssue={selectedIssue}
355 lastSnippetOfLastGroup,
360 issuesByLine: IssuesByLine;
361 lastSnippetOfLastGroup: boolean;
362 locationsByLine: { [line: number]: LinearIssueLocation[] };
363 snippet: SourceLine[];
367 renderAdditionalChildInLine={this.renderIssuesList}
368 component={this.props.snippetGroup.component}
369 duplications={this.props.duplications}
370 duplicationsByLine={this.props.duplicationsByLine}
371 expandBlock={this.expandBlock}
372 handleCloseIssues={this.handleCloseIssues}
373 handleOpenIssues={this.handleOpenIssues}
374 handleSymbolClick={this.handleSymbolClick}
375 highlightedLocationMessage={this.props.highlightedLocationMessage}
376 highlightedSymbols={this.state.highlightedSymbols}
378 issue={this.props.issue}
379 issuesByLine={issuesByLine}
380 lastSnippetOfLastGroup={lastSnippetOfLastGroup}
381 loadDuplications={this.loadDuplications}
382 locations={this.props.locations}
383 locationsByLine={locationsByLine}
384 onLocationSelect={this.props.onLocationSelect}
385 openIssuesByLine={this.state.openIssuesByLine}
386 renderDuplicationPopup={this.renderDuplicationPopup}
387 scroll={this.props.scroll}
396 isLastOccurenceOfPrimaryComponent,
403 const { additionalLines, loading, snippets } = this.state;
405 issue.component === snippetGroup.component.key && issue.textRange !== undefined
406 ? locationsByLine([issue])
410 snippets.length === 1 &&
411 snippetGroup.component.measures &&
412 snippets[0].end - snippets[0].start ===
413 parseInt(snippetGroup.component.measures.lines || '', 10);
415 const snippetLines = linesForSnippets(snippets, {
416 ...snippetGroup.sources,
420 const isFlow = issue.secondaryLocations.length === 0;
421 const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
424 <div className="component-source-container" ref={this.rootNodeRef}>
425 <SourceViewerHeaderSlim
426 branchLike={branchLike}
427 expandable={!fullyShown}
429 onExpand={this.expandComponent}
430 sourceViewerFile={snippetGroup.component}
432 {issue.component === snippetGroup.component.key && issue.textRange === undefined && (
433 <div className="padded-top padded-left padded-right">
436 onChange={this.props.onIssueChange}
438 onPopupToggle={this.props.onIssuePopupToggle}
439 openPopup={issuePopup && issuePopup.issue === issue.key ? issuePopup.name : undefined}
444 {snippetLines.map((snippet, index) => (
445 <div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
446 {this.renderSnippet({
448 index: snippets[index].index,
450 locationsByLine: includeIssueLocation ? locations : {},
451 lastSnippetOfLastGroup: lastSnippetGroup && index === snippets.length - 1