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