]> source.dussan.org Git - sonarqube.git/blob
2cc7de55fe971c008287f1255635b08092866044
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2020 SonarSource SA
4  * mailto:info AT sonarsource DOT com
5  *
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.
10  *
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.
15  *
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.
19  */
20 import * as React from 'react';
21 import { getSources } from '../../../api/components';
22 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
23 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
24 import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
25 import { getBranchLikeQuery } from '../../../helpers/branch-like';
26 import { BranchLike } from '../../../types/branch-like';
27 import SnippetViewer from './SnippetViewer';
28 import {
29   createSnippets,
30   expandSnippet,
31   EXPAND_BY_LINES,
32   linesForSnippets,
33   MERGE_DISTANCE
34 } from './utils';
35
36 interface Props {
37   branchLike: BranchLike | undefined;
38   duplications?: T.Duplication[];
39   duplicationsByLine?: { [line: number]: number[] };
40   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
41   issue: T.Issue;
42   issuePopup?: { issue: string; name: string };
43   issuesByLine: T.IssuesByLine;
44   lastSnippetGroup: boolean;
45   loadDuplications: (component: string, line: T.SourceLine) => void;
46   locations: T.FlowLocation[];
47   onIssueChange: (issue: T.Issue) => void;
48   onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
49   onLocationSelect: (index: number) => void;
50   renderDuplicationPopup: (
51     component: T.SourceViewerFile,
52     index: number,
53     line: number
54   ) => React.ReactNode;
55   scroll?: (element: HTMLElement, offset: number) => void;
56   snippetGroup: T.SnippetGroup;
57 }
58
59 interface State {
60   additionalLines: { [line: number]: T.SourceLine };
61   highlightedSymbols: string[];
62   loading: boolean;
63   openIssuesByLine: T.Dict<boolean>;
64   snippets: T.Snippet[];
65 }
66
67 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
68   mounted = false;
69   rootNodeRef = React.createRef<HTMLDivElement>();
70   state: State = {
71     additionalLines: {},
72     highlightedSymbols: [],
73     loading: false,
74     openIssuesByLine: {},
75     snippets: []
76   };
77
78   componentDidMount() {
79     this.mounted = true;
80     this.createSnippetsFromProps();
81   }
82
83   componentWillUnmount() {
84     this.mounted = false;
85   }
86
87   createSnippetsFromProps() {
88     const { issue, snippetGroup } = this.props;
89
90     const snippets = createSnippets({
91       component: snippetGroup.component.key,
92       issue,
93       locations: snippetGroup.locations
94     });
95
96     this.setState({ snippets });
97   }
98
99   getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
100     const root = this.rootNodeRef.current;
101     if (!root) {
102       return undefined;
103     }
104     const element = root.querySelector(`#snippet-wrapper-${index}`);
105     if (!element) {
106       return undefined;
107     }
108     const wrapper = element.querySelector<HTMLElement>('.snippet');
109     if (!wrapper) {
110       return undefined;
111     }
112     const table = wrapper.firstChild as HTMLElement;
113     if (!table) {
114       return undefined;
115     }
116
117     return { wrapper, table };
118   }
119
120   /*
121    * Clean after animation
122    */
123   cleanDom(index: number) {
124     const nodes = this.getNodes(index);
125
126     if (!nodes) {
127       return;
128     }
129
130     const { wrapper, table } = nodes;
131
132     table.style.marginTop = '';
133     wrapper.style.maxHeight = '';
134   }
135
136   setMaxHeight(index: number, value?: number, up = false) {
137     const nodes = this.getNodes(index);
138
139     if (!nodes) {
140       return;
141     }
142
143     const { wrapper, table } = nodes;
144
145     const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;
146
147     if (up) {
148       const startHeight = wrapper.getBoundingClientRect().height;
149       table.style.transition = 'none';
150       table.style.marginTop = `${startHeight - maxHeight}px`;
151
152       // animate!
153       setTimeout(() => {
154         table.style.transition = '';
155         table.style.marginTop = '0px';
156         wrapper.style.maxHeight = `${maxHeight + 20}px`;
157       }, 0);
158     } else {
159       wrapper.style.maxHeight = `${maxHeight + 20}px`;
160     }
161   }
162
163   expandBlock = (snippetIndex: number, direction: T.ExpandDirection): Promise<void> => {
164     const { branchLike, snippetGroup } = this.props;
165     const { key } = snippetGroup.component;
166     const { snippets } = this.state;
167     const snippet = snippets.find(s => s.index === snippetIndex);
168     if (!snippet) {
169       return Promise.reject();
170     }
171     // Extend by EXPAND_BY_LINES and add buffer for merging snippets
172     const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
173     const range =
174       direction === 'up'
175         ? {
176             from: Math.max(1, snippet.start - extension),
177             to: snippet.start - 1
178           }
179         : {
180             from: snippet.end + 1,
181             to: snippet.end + extension
182           };
183     return getSources({
184       key,
185       ...range,
186       ...getBranchLikeQuery(branchLike)
187     })
188       .then(lines =>
189         lines.reduce((lineMap: T.Dict<T.SourceLine>, line) => {
190           line.coverageStatus = getCoverageStatus(line);
191           lineMap[line.line] = line;
192           return lineMap;
193         }, {})
194       )
195       .then(newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped));
196   };
197
198   animateBlockExpansion(
199     snippetIndex: number,
200     direction: T.ExpandDirection,
201     newLinesMapped: T.Dict<T.SourceLine>
202   ): Promise<void> {
203     if (this.mounted) {
204       const { snippets } = this.state;
205
206       const newSnippets = expandSnippet({
207         direction,
208         snippetIndex,
209         snippets
210       });
211
212       const deletedSnippets = newSnippets.filter(s => s.toDelete);
213
214       // set max-height to current height for CSS transitions
215       deletedSnippets.forEach(s => this.setMaxHeight(s.index));
216       this.setMaxHeight(snippetIndex);
217
218       return new Promise(resolve => {
219         this.setState(
220           ({ additionalLines, snippets }) => {
221             const combinedLines = { ...additionalLines, ...newLinesMapped };
222             return {
223               additionalLines: combinedLines,
224               snippets
225             };
226           },
227           () => {
228             // Set max-height 0 to trigger CSS transitions
229             deletedSnippets.forEach(s => {
230               this.setMaxHeight(s.index, 0);
231             });
232             this.setMaxHeight(snippetIndex, undefined, direction === 'up');
233
234             // Wait for transition to finish before updating dom
235             setTimeout(() => {
236               this.setState({ snippets: newSnippets.filter(s => !s.toDelete) }, resolve);
237               this.cleanDom(snippetIndex);
238             }, 200);
239           }
240         );
241       });
242     }
243     return Promise.resolve();
244   }
245
246   expandComponent = () => {
247     const { branchLike, snippetGroup } = this.props;
248     const { key } = snippetGroup.component;
249
250     this.setState({ loading: true });
251
252     getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
253       lines => {
254         if (this.mounted) {
255           this.setState(({ additionalLines }) => {
256             const combinedLines = { ...additionalLines, ...lines };
257             return {
258               additionalLines: combinedLines,
259               loading: false,
260               snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
261             };
262           });
263         }
264       },
265       () => {
266         if (this.mounted) {
267           this.setState({ loading: false });
268         }
269       }
270     );
271   };
272
273   handleOpenIssues = (line: T.SourceLine) => {
274     this.setState(state => ({
275       openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true }
276     }));
277   };
278
279   handleCloseIssues = (line: T.SourceLine) => {
280     this.setState(state => ({
281       openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false }
282     }));
283   };
284
285   handleSymbolClick = (clickedSymbols: string[]) => {
286     this.setState(({ highlightedSymbols }) => {
287       const newHighlightedSymbols = clickedSymbols.filter(
288         symb => !highlightedSymbols.includes(symb)
289       );
290       return { highlightedSymbols: newHighlightedSymbols };
291     });
292   };
293
294   loadDuplications = (line: T.SourceLine) => {
295     this.props.loadDuplications(this.props.snippetGroup.component.key, line);
296   };
297
298   renderDuplicationPopup = (index: number, line: number) => {
299     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
300   };
301
302   renderSnippet({
303     index,
304     issuesByLine,
305     lastSnippetOfLastGroup,
306     locationsByLine,
307     snippet
308   }: {
309     index: number;
310     issuesByLine: T.IssuesByLine;
311     lastSnippetOfLastGroup: boolean;
312     locationsByLine: { [line: number]: T.LinearIssueLocation[] };
313     snippet: T.SourceLine[];
314   }) {
315     return (
316       <SnippetViewer
317         branchLike={this.props.branchLike}
318         component={this.props.snippetGroup.component}
319         duplications={this.props.duplications}
320         duplicationsByLine={this.props.duplicationsByLine}
321         expandBlock={this.expandBlock}
322         handleCloseIssues={this.handleCloseIssues}
323         handleOpenIssues={this.handleOpenIssues}
324         handleSymbolClick={this.handleSymbolClick}
325         highlightedLocationMessage={this.props.highlightedLocationMessage}
326         highlightedSymbols={this.state.highlightedSymbols}
327         index={index}
328         issue={this.props.issue}
329         issuePopup={this.props.issuePopup}
330         issuesByLine={issuesByLine}
331         lastSnippetOfLastGroup={lastSnippetOfLastGroup}
332         loadDuplications={this.loadDuplications}
333         locations={this.props.locations}
334         locationsByLine={locationsByLine}
335         onIssueChange={this.props.onIssueChange}
336         onIssuePopupToggle={this.props.onIssuePopupToggle}
337         onLocationSelect={this.props.onLocationSelect}
338         openIssuesByLine={this.state.openIssuesByLine}
339         renderDuplicationPopup={this.renderDuplicationPopup}
340         scroll={this.props.scroll}
341         snippet={snippet}
342       />
343     );
344   }
345
346   render() {
347     const { branchLike, issue, issuesByLine, lastSnippetGroup, snippetGroup } = this.props;
348     const { additionalLines, loading, snippets } = this.state;
349     const locations =
350       issue.component === snippetGroup.component.key ? locationsByLine([issue]) : {};
351
352     const fullyShown =
353       snippets.length === 1 &&
354       snippetGroup.component.measures &&
355       snippets[0].end - snippets[0].start ===
356         parseInt(snippetGroup.component.measures.lines || '', 10);
357
358     const snippetLines = linesForSnippets(snippets, {
359       ...snippetGroup.sources,
360       ...additionalLines
361     });
362
363     const isFlow = issue.secondaryLocations.length === 0;
364     const includeIssueLocation = (snippetIndex: number) =>
365       isFlow ? lastSnippetGroup && snippetIndex === snippets.length - 1 : snippetIndex === 0;
366
367     return (
368       <div className="component-source-container" ref={this.rootNodeRef}>
369         <SourceViewerHeaderSlim
370           branchLike={branchLike}
371           expandable={!fullyShown}
372           loading={loading}
373           onExpand={this.expandComponent}
374           sourceViewerFile={snippetGroup.component}
375         />
376         {snippetLines.map((snippet, index) => (
377           <div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
378             {this.renderSnippet({
379               snippet,
380               index: snippets[index].index,
381               issuesByLine,
382               locationsByLine: includeIssueLocation(index) ? locations : {},
383               lastSnippetOfLastGroup: lastSnippetGroup && index === snippets.length - 1
384             })}
385           </div>
386         ))}
387       </div>
388     );
389   }
390 }