]> source.dussan.org Git - sonarqube.git/blob
00a82856a42d70e2e4233aaafe87d82e2bba9487
[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 * as React from 'react';
21 import { getSources } from '../../../api/components';
22 import Issue from '../../../components/issue/Issue';
23 import SecondaryIssue from '../../../components/issue/SecondaryIssue';
24 import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
25 import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
26 import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim';
27 import { getBranchLikeQuery } from '../../../helpers/branch-like';
28 import { BranchLike } from '../../../types/branch-like';
29 import { isFile } from '../../../types/component';
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   onIssueSelect: (issueKey: string) => void;
66   onIssuePopupToggle: (issue: string, popupName: string, open?: boolean) => void;
67   onLocationSelect: (index: number) => void;
68   renderDuplicationPopup: (
69     component: SourceViewerFile,
70     index: number,
71     line: number
72   ) => React.ReactNode;
73   scroll?: (element: HTMLElement, offset: number) => void;
74   snippetGroup: SnippetGroup;
75 }
76
77 interface State {
78   additionalLines: { [line: number]: SourceLine };
79   highlightedSymbols: string[];
80   loading: 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     snippets: []
92   };
93
94   componentDidMount() {
95     this.mounted = true;
96     this.createSnippetsFromProps();
97   }
98
99   componentWillUnmount() {
100     this.mounted = false;
101   }
102
103   createSnippetsFromProps() {
104     const { issue, snippetGroup } = this.props;
105
106     const snippets = createSnippets({
107       component: snippetGroup.component.key,
108       issue,
109       locations: snippetGroup.locations
110     });
111
112     this.setState({ snippets });
113   }
114
115   getNodes(index: number): { wrapper: HTMLElement; table: HTMLElement } | undefined {
116     const root = this.rootNodeRef.current;
117     if (!root) {
118       return undefined;
119     }
120     const element = root.querySelector(`#snippet-wrapper-${index}`);
121     if (!element) {
122       return undefined;
123     }
124     const wrapper = element.querySelector<HTMLElement>('.snippet');
125     if (!wrapper) {
126       return undefined;
127     }
128     const table = wrapper.firstChild as HTMLElement;
129     if (!table) {
130       return undefined;
131     }
132
133     return { wrapper, table };
134   }
135
136   /*
137    * Clean after animation
138    */
139   cleanDom(index: number) {
140     const nodes = this.getNodes(index);
141
142     if (!nodes) {
143       return;
144     }
145
146     const { wrapper, table } = nodes;
147
148     table.style.marginTop = '';
149     wrapper.style.maxHeight = '';
150   }
151
152   setMaxHeight(index: number, value?: number, up = false) {
153     const nodes = this.getNodes(index);
154
155     if (!nodes) {
156       return;
157     }
158
159     const { wrapper, table } = nodes;
160
161     const maxHeight = value !== undefined ? value : table.getBoundingClientRect().height;
162
163     if (up) {
164       const startHeight = wrapper.getBoundingClientRect().height;
165       table.style.transition = 'none';
166       table.style.marginTop = `${startHeight - maxHeight}px`;
167
168       // animate!
169       setTimeout(() => {
170         table.style.transition = '';
171         table.style.marginTop = '0px';
172         wrapper.style.maxHeight = `${maxHeight + 20}px`;
173       }, 0);
174     } else {
175       wrapper.style.maxHeight = `${maxHeight + 20}px`;
176     }
177   }
178
179   expandBlock = (snippetIndex: number, direction: ExpandDirection): Promise<void> => {
180     const { branchLike, snippetGroup } = this.props;
181     const { key } = snippetGroup.component;
182     const { snippets } = this.state;
183     const snippet = snippets.find(s => s.index === snippetIndex);
184     if (!snippet) {
185       return Promise.reject();
186     }
187     // Extend by EXPAND_BY_LINES and add buffer for merging snippets
188     const extension = EXPAND_BY_LINES + MERGE_DISTANCE - 1;
189     const range =
190       direction === 'up'
191         ? {
192             from: Math.max(1, snippet.start - extension),
193             to: snippet.start - 1
194           }
195         : {
196             from: snippet.end + 1,
197             to: snippet.end + extension
198           };
199     return getSources({
200       key,
201       ...range,
202       ...getBranchLikeQuery(branchLike)
203     })
204       .then(lines =>
205         lines.reduce((lineMap: Dict<SourceLine>, line) => {
206           line.coverageStatus = getCoverageStatus(line);
207           lineMap[line.line] = line;
208           return lineMap;
209         }, {})
210       )
211       .then(newLinesMapped => this.animateBlockExpansion(snippetIndex, direction, newLinesMapped));
212   };
213
214   animateBlockExpansion(
215     snippetIndex: number,
216     direction: ExpandDirection,
217     newLinesMapped: Dict<SourceLine>
218   ): Promise<void> {
219     if (this.mounted) {
220       const { snippets } = this.state;
221
222       const newSnippets = expandSnippet({
223         direction,
224         snippetIndex,
225         snippets
226       });
227
228       const deletedSnippets = newSnippets.filter(s => s.toDelete);
229
230       // set max-height to current height for CSS transitions
231       deletedSnippets.forEach(s => this.setMaxHeight(s.index));
232       this.setMaxHeight(snippetIndex);
233
234       return new Promise(resolve => {
235         this.setState(
236           ({ additionalLines, snippets }) => {
237             const combinedLines = { ...additionalLines, ...newLinesMapped };
238             return {
239               additionalLines: combinedLines,
240               snippets
241             };
242           },
243           () => {
244             // Set max-height 0 to trigger CSS transitions
245             deletedSnippets.forEach(s => {
246               this.setMaxHeight(s.index, 0);
247             });
248             this.setMaxHeight(snippetIndex, undefined, direction === 'up');
249
250             // Wait for transition to finish before updating dom
251             setTimeout(() => {
252               this.setState({ snippets: newSnippets.filter(s => !s.toDelete) }, resolve);
253               this.cleanDom(snippetIndex);
254             }, 200);
255           }
256         );
257       });
258     }
259     return Promise.resolve();
260   }
261
262   expandComponent = () => {
263     const { branchLike, snippetGroup } = this.props;
264     const { key } = snippetGroup.component;
265
266     this.setState({ loading: true });
267
268     getSources({ key, ...getBranchLikeQuery(branchLike) }).then(
269       lines => {
270         if (this.mounted) {
271           this.setState(({ additionalLines }) => {
272             const combinedLines = { ...additionalLines, ...lines };
273             return {
274               additionalLines: combinedLines,
275               loading: false,
276               snippets: [{ start: 0, end: lines[lines.length - 1].line, index: -1 }]
277             };
278           });
279         }
280       },
281       () => {
282         if (this.mounted) {
283           this.setState({ loading: false });
284         }
285       }
286     );
287   };
288
289   handleSymbolClick = (clickedSymbols: string[]) => {
290     this.setState(({ highlightedSymbols }) => {
291       const newHighlightedSymbols = clickedSymbols.filter(
292         symb => !highlightedSymbols.includes(symb)
293       );
294       return { highlightedSymbols: newHighlightedSymbols };
295     });
296   };
297
298   loadDuplications = (line: SourceLine) => {
299     this.props.loadDuplications(this.props.snippetGroup.component.key, line);
300   };
301
302   renderDuplicationPopup = (index: number, line: number) => {
303     return this.props.renderDuplicationPopup(this.props.snippetGroup.component, index, line);
304   };
305
306   renderIssuesList = (line: SourceLine) => {
307     const {
308       isLastOccurenceOfPrimaryComponent,
309       issue,
310       issuesByLine,
311       snippetGroup,
312       branchLike
313     } = this.props;
314     const locations =
315       issue.component === snippetGroup.component.key && issue.textRange !== undefined
316         ? locationsByLine([issue])
317         : {};
318
319     const isFlow = issue.secondaryLocations.length === 0;
320     const includeIssueLocation = isFlow ? isLastOccurenceOfPrimaryComponent : true;
321     const issuesForLine = issuesByLine[line.line] || [];
322     const issueLocations = includeIssueLocation ? locations[line.line] : [];
323
324     return (
325       issuesForLine.length > 0 && (
326         <div className="issue-list">
327           {issuesForLine.map(issueToDisplay => {
328             if (issueToDisplay.key === issue.key && issueLocations && issueLocations.length) {
329               return (
330                 <Issue
331                   branchLike={branchLike}
332                   displayWhyIsThisAnIssue={false}
333                   issue={issueToDisplay}
334                   key={issueToDisplay.key}
335                   onChange={this.props.onIssueChange}
336                   onPopupToggle={this.props.onIssuePopupToggle}
337                   openPopup={
338                     this.props.issuePopup && this.props.issuePopup.issue === issueToDisplay.key
339                       ? this.props.issuePopup.name
340                       : undefined
341                   }
342                   selected={issue.key === issueToDisplay.key}
343                 />
344               );
345             }
346             return (
347               <SecondaryIssue
348                 key={issueToDisplay.key}
349                 issue={issueToDisplay}
350                 onClick={this.props.onIssueSelect}
351               />
352             );
353           })}
354         </div>
355       )
356     );
357   };
358
359   renderSnippet({
360     index,
361     lastSnippetOfLastGroup,
362     locationsByLine,
363     snippet
364   }: {
365     index: number;
366     lastSnippetOfLastGroup: boolean;
367     locationsByLine: { [line: number]: LinearIssueLocation[] };
368     snippet: SourceLine[];
369   }) {
370     return (
371       <SnippetViewer
372         renderAdditionalChildInLine={this.renderIssuesList}
373         component={this.props.snippetGroup.component}
374         duplications={this.props.duplications}
375         duplicationsByLine={this.props.duplicationsByLine}
376         expandBlock={this.expandBlock}
377         handleSymbolClick={this.handleSymbolClick}
378         highlightedLocationMessage={this.props.highlightedLocationMessage}
379         highlightedSymbols={this.state.highlightedSymbols}
380         index={index}
381         issue={this.props.issue}
382         lastSnippetOfLastGroup={lastSnippetOfLastGroup}
383         loadDuplications={this.loadDuplications}
384         locations={this.props.locations}
385         locationsByLine={locationsByLine}
386         onLocationSelect={this.props.onLocationSelect}
387         renderDuplicationPopup={this.renderDuplicationPopup}
388         scroll={this.props.scroll}
389         snippet={snippet}
390       />
391     );
392   }
393
394   render() {
395     const {
396       branchLike,
397       isLastOccurenceOfPrimaryComponent,
398       issue,
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 && isFile(snippetGroup.component.q)}
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               onPopupToggle={this.props.onIssuePopupToggle}
438               openPopup={issuePopup && issuePopup.issue === issue.key ? issuePopup.name : undefined}
439               selected={true}
440             />
441           </div>
442         )}
443         {snippetLines.map((snippet, index) => (
444           <div id={`snippet-wrapper-${snippets[index].index}`} key={snippets[index].index}>
445             {this.renderSnippet({
446               snippet,
447               index: snippets[index].index,
448               locationsByLine: includeIssueLocation ? locations : {},
449               lastSnippetOfLastGroup: lastSnippetGroup && index === snippets.length - 1
450             })}
451           </div>
452         ))}
453       </div>
454     );
455   }
456 }