]> source.dussan.org Git - sonarqube.git/blob
aad18317be300686ad1b8c640a7fd9f6f2b05fbe
[sonarqube.git] /
1 /*
2  * SonarQube
3  * Copyright (C) 2009-2022 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 { 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';
30 import {
31   Dict,
32   Duplication,
33   ExpandDirection,
34   FlowLocation,
35   Issue as TypeIssue,
36   IssuesByLine,
37   LinearIssueLocation,
38   Snippet,
39   SnippetGroup,
40   SourceLine,
41   SourceViewerFile
42 } from '../../../types/types';
43 import SnippetViewer from './SnippetViewer';
44 import {
45   createSnippets,
46   expandSnippet,
47   EXPAND_BY_LINES,
48   linesForSnippets,
49   MERGE_DISTANCE
50 } from './utils';
51
52 interface Props {
53   branchLike: BranchLike | undefined;
54   duplications?: Duplication[];
55   duplicationsByLine?: { [line: number]: number[] };
56   highlightedLocationMessage: { index: number; text: string | undefined } | undefined;
57   isLastOccurenceOfPrimaryComponent: boolean;
58   issue: TypeIssue;
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,
69     index: number,
70     line: number
71   ) => React.ReactNode;
72   scroll?: (element: HTMLElement, offset: number) => void;
73   snippetGroup: SnippetGroup;
74 }
75
76 interface State {
77   additionalLines: { [line: number]: SourceLine };
78   highlightedSymbols: string[];
79   loading: boolean;
80   openIssuesByLine: Dict<boolean>;
81   snippets: Snippet[];
82 }
83
84 export default class ComponentSourceSnippetGroupViewer extends React.PureComponent<Props, State> {
85   mounted = false;
86   rootNodeRef = React.createRef<HTMLDivElement>();
87   state: State = {
88     additionalLines: {},
89     highlightedSymbols: [],
90     loading: false,
91     openIssuesByLine: {},
92     snippets: []
93   };
94
95   componentDidMount() {
96     this.mounted = true;
97     this.createSnippetsFromProps();
98   }
99
100   componentWillUnmount() {
101     this.mounted = false;
102   }
103
104   createSnippetsFromProps() {
105     const { issue, snippetGroup } = this.props;
106
107     const snippets = createSnippets({
108       component: snippetGroup.component.key,
109       issue,
110       locations: snippetGroup.locations
111     });
112
113     this.setState({ snippets });
114   }
115
116   getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
117     const root = this.rootNodeRef.current;
118     if (!root) {
119       return undefined;
120     }
121     const element = root.querySelector(`#snippet-wrapper-${index}`);
122     if (!element) {
123       return undefined;
124     }
125     const wrapper = element.querySelector<HTMLElement>('.snippet');
126     if (!wrapper) {
127       return undefined;
128     }
129     const table = wrapper.firstChild as HTMLElement;
130     if (!table) {
131       return undefined;
132     }
133
134     return { wrapper, table };
135   }
136
137   /*
138    * Clean after animation
139    */
140   cleanDom(index: number) {
141     const nodes = this.getNodes(index);
142
143     if (!nodes) {
144       return;
145     }
146
147     const { wrapper, table } = nodes;
148
149     table.style.marginTop = '';
150     wrapper.style.maxHeight = '';
151   }
152
153   setMaxHeight(index: number, value?: number, up = false) {
154     const nodes = this.getNodes(index);
155
156     if (!nodes) {
157       return;
158     }
159
160     const { wrapper, table } = nodes;
161
162     const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;
163
164     if (up) {
165       const startHeight = wrapper.getBoundingClientRect().height;
166       table.style.transition = 'none';
167       table.style.marginTop = `${startHeight - maxHeight}px`;
168
169       // animate!
170       setTimeout(() => {
171         table.style.transition = '';
172         table.style.marginTop = '0px';
173         wrapper.style.maxHeight = `${maxHeight + 20}px`;
174       }, 0);
175     } else {
176       wrapper.style.maxHeight = `${maxHeight + 20}px`;
177     }
178   }
179
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);
185     if (!snippet) {
186       return Promise.reject();
187     }
188     // Extend by EXPAND_BY_LINES and add buffer for merging snippets
189     const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
190     const range =
191       direction === 'up'
192         ? {
193             from: Math.max(1, snippet.start - extension),
194             to: snippet.start - 1
195           }
196         : {
197             from: snippet.end + 1,
198             to: snippet.end + extension
199           };
200     return getSources({
201       key,
202       ...range,
203       ...getBranchLikeQuery(branchLike)
204     })
205       .then(lines =>
206         lines.reduce((lineMap: Dict<SourceLine>, line) => {
207           line.coverageStatus = getCoverageStatus(line);
208           lineMap[line.line] = line;
209           return lineMap;
210         }, {})
211       )
212       .then(newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped));
213   };
214
215   animateBlockExpansion(
216     snippetIndex: number,
217     direction: ExpandDirection,
218     newLinesMapped: Dict<SourceLine>
219   ): Promise<void> {
220     if (this.mounted) {
221       const { snippets } = this.state;
222
223       const newSnippets = expandSnippet({
224         direction,
225         snippetIndex,
226         snippets
227       });
228
229       const deletedSnippets = newSnippets.filter(s => s.toDelete);
230
231       // set max-height to current height for CSS transitions
232       deletedSnippets.forEach(s => this.setMaxHeight(s.index));
233       this.setMaxHeight(snippetIndex);
234
235       return new Promise(resolve => {
236         this.setState(
237           ({ additionalLines, snippets }) => {
238             const combinedLines = { ...additionalLines, ...newLinesMapped };
239             return {
240               additionalLines: combinedLines,
241               snippets
242             };
243           },
244           () => {
245             // Set max-height 0 to trigger CSS transitions
246             deletedSnippets.forEach(s => {
247               this.setMaxHeight(s.index, 0);
248             });
249             this.setMaxHeight(snippetIndex, undefined, direction === 'up');
250
251             // Wait for transition to finish before updating dom
252             setTimeout(() => {
253               this.setState({ snippets: newSnippets.filter(s => !s.toDelete) }, resolve);
254               this.cleanDom(snippetIndex);
255             }, 200);
256           }
257         );
258       });
259     }
260     return Promise.resolve();
261   }
262
263   expandComponent = () => {
264     const { branchLike, snippetGroup } = this.props;
265     const { key } = snippetGroup.component;
266
267     this.setState({ loading: true });
268
269     getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
270       lines => {
271         if (this.mounted) {
272           this.setState(({ additionalLines }) => {
273             const combinedLines = { ...additionalLines, ...lines };
274             return {
275               additionalLines: combinedLines,
276               loading: false,
277               snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
278             };
279           });
280         }
281       },
282       () => {
283         if (this.mounted) {
284           this.setState({ loading: false });
285         }
286       }
287     );
288   };
289
290   handleOpenIssues = (line: SourceLine) => {
291     this.setState(state => ({
292       openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true }
293     }));
294   };
295
296   handleCloseIssues = (line: SourceLine) => {
297     this.setState(state => ({
298       openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false }
299     }));
300   };
301
302   handleSymbolClick = (clickedSymbols: string[]) => {
303     this.setState(({ highlightedSymbols }) => {
304       const newHighlightedSymbols = clickedSymbols.filter(
305         symb => !highlightedSymbols.includes(symb)
306       );
307       return { highlightedSymbols: newHighlightedSymbols };
308     });
309   };
310
311   loadDuplications = (line: SourceLine) => {
312     this.props.loadDuplications(this.props.snippetGroup.component.key, line);
313   };
314
315   renderDuplicationPopup = (index: number, line: number) => {
316     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
317   };
318
319   renderIssuesList = (line: SourceLine) => {
320     const { openIssuesByLine } = this.state;
321
322     const { isLastOccurenceOfPrimaryComponent, issue, issuesByLine, snippetGroup } = this.props;
323     const locations =
324       issue.component === snippetGroup.component.key && issue.textRange !== undefined
325         ? locationsByLine([issue])
326         : {};
327
328     const isFlow = issue.secondaryLocations.length === 0;
329     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
330
331     const issuesForLine = issuesByLine[line.line] || [];
332
333     const selectedIssue = issuesForLine.find(i => i.key === issue.key)?.key;
334
335     const issueLocationsByLine = includeIssueLocation ? locations : {};
336     return (
337       <LineIssuesList
338         issueLocationsByLine={issueLocationsByLine}
339         issuesForLine={issuesForLine}
340         line={line}
341         openIssuesByLine={openIssuesByLine}
342         branchLike={this.props.branchLike}
343         issuePopup={this.props.issuePopup}
344         onIssueChange={this.props.onIssueChange}
345         onIssueClick={noop}
346         onIssuePopupToggle={this.props.onIssuePopupToggle}
347         selectedIssue={selectedIssue}
348       />
349     );
350   };
351
352   renderSnippet({
353     index,
354     issuesByLine,
355     lastSnippetOfLastGroup,
356     locationsByLine,
357     snippet
358   }: {
359     index: number;
360     issuesByLine: IssuesByLine;
361     lastSnippetOfLastGroup: boolean;
362     locationsByLine: { [line: number]: LinearIssueLocation[] };
363     snippet: SourceLine[];
364   }) {
365     return (
366       <SnippetViewer
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}
377         index={index}
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}
388         snippet={snippet}
389       />
390     );
391   }
392
393   render() {
394     const {
395       branchLike,
396       isLastOccurenceOfPrimaryComponent,
397       issue,
398       issuesByLine,
399       issuePopup,
400       lastSnippetGroup,
401       snippetGroup
402     } = this.props;
403     const { additionalLines, loading, snippets } = this.state;
404     const locations =
405       issue.component === snippetGroup.component.key && issue.textRange !== undefined
406         ? locationsByLine([issue])
407         : {};
408
409     const fullyShown =
410       snippets.length === 1 &&
411       snippetGroup.component.measures &&
412       snippets[0].end - snippets[0].start ===
413         parseInt(snippetGroup.component.measures.lines || '', 10);
414
415     const snippetLines = linesForSnippets(snippets, {
416       ...snippetGroup.sources,
417       ...additionalLines
418     });
419
420     const isFlow = issue.secondaryLocations.length === 0;
421     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
422
423     return (
424       <div className="component-source-container" ref={this.rootNodeRef}>
425         <SourceViewerHeaderSlim
426           branchLike={branchLike}
427           expandable={!fullyShown}
428           loading={loading}
429           onExpand={this.expandComponent}
430           sourceViewerFile={snippetGroup.component}
431         />
432         {issue.component === snippetGroup.component.key && issue.textRange === undefined && (
433           <div className="padded-top padded-left padded-right">
434             <Issue
435               issue={issue}
436               onChange={this.props.onIssueChange}
437               onClick={noop}
438               onPopupToggle={this.props.onIssuePopupToggle}
439               openPopup={issuePopup && issuePopup.issue === issue.key ? issuePopup.name : undefined}
440               selected={true}
441             />
442           </div>
443         )}
444         {snippetLines.map((snippet, index) => (
445           <div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
446             {this.renderSnippet({
447               snippet,
448               index: snippets[index].index,
449               issuesByLine,
450               locationsByLine: includeIssueLocation ? locations : {},
451               lastSnippetOfLastGroup: lastSnippetGroup && index === snippets.length - 1
452             })}
453           </div>
454         ))}
455       </div>
456     );
457   }
458 }