diff options
Diffstat (limited to 'server/sonar-web/src/main/js/components/SourceViewer')
-rw-r--r-- | server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx | 653 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx | 673 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx | 84 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-test.tsx (renamed from server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewerBase-test.tsx) | 8 | ||||
-rw-r--r-- | server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewer-test.tsx.snap (renamed from server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewerBase-test.tsx.snap) | 0 |
5 files changed, 732 insertions, 686 deletions
diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx index 87d95775bdf..3c43b8ce50b 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx @@ -17,10 +17,651 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { lazyLoadComponent } from '../lazyLoadComponent'; +import { intersection, uniqBy } from 'lodash'; +import * as React from 'react'; +import { + getComponentData, + getComponentForSourceViewer, + getDuplications, + getSources +} from '../../api/components'; +import { getBranchLikeQuery, isSameBranchLike } from '../../helpers/branch-like'; +import { translate } from '../../helpers/l10n'; +import { HttpStatus } from '../../helpers/request'; +import { BranchLike } from '../../types/branch-like'; +import { + Dict, + DuplicatedFile, + Duplication, + FlowLocation, + Issue, + LinearIssueLocation, + Measure, + SourceLine, + SourceViewerFile +} from '../../types/types'; +import { Alert } from '../ui/Alert'; +import { WorkspaceContext } from '../workspace/context'; +import DuplicationPopup from './components/DuplicationPopup'; +import { + filterDuplicationBlocksByLine, + getDuplicationBlocksForIndex, + isDuplicationBlockInRemovedComponent +} from './helpers/duplications'; +import getCoverageStatus from './helpers/getCoverageStatus'; +import { + duplicationsByLine, + issuesByLine, + locationsByLine, + symbolsByLine +} from './helpers/indexing'; +import { LINES_TO_LOAD } from './helpers/lines'; +import defaultLoadIssues from './helpers/loadIssues'; +import SourceViewerCode from './SourceViewerCode'; +import { SourceViewerContext } from './SourceViewerContext'; +import SourceViewerHeader from './SourceViewerHeader'; +import SourceViewerHeaderSlim from './SourceViewerHeaderSlim'; +import './styles.css'; -const SourceViewer = lazyLoadComponent( - () => import(/* webpackPrefetch: true */ './SourceViewerBase'), - 'SourceViewer' -); -export default SourceViewer; +export interface Props { + aroundLine?: number; + branchLike: BranchLike | undefined; + component: string; + componentMeasures?: Measure[]; + displayAllIssues?: boolean; + displayIssueLocationsCount?: boolean; + displayIssueLocationsLink?: boolean; + displayLocationMarkers?: boolean; + highlightedLine?: number; + // `undefined` elements mean they are located in a different file, + // but kept to maintaint the location indexes + highlightedLocations?: (FlowLocation | undefined)[]; + highlightedLocationMessage?: { index: number; text: string | undefined }; + loadIssues?: ( + component: string, + from: number, + to: number, + branchLike: BranchLike | undefined + ) => Promise<Issue[]>; + onLoaded?: (component: SourceViewerFile, sources: SourceLine[], issues: Issue[]) => void; + onLocationSelect?: (index: number) => void; + onIssueChange?: (issue: Issue) => void; + onIssueSelect?: (issueKey: string) => void; + onIssueUnselect?: () => void; + scroll?: (element: HTMLElement) => void; + selectedIssue?: string; + showMeasures?: boolean; + metricKey?: string; + slimHeader?: boolean; +} + +interface State { + component?: SourceViewerFile; + duplicatedFiles?: Dict<DuplicatedFile>; + duplications?: Duplication[]; + duplicationsByLine: { [line: number]: number[] }; + hasSourcesAfter: boolean; + highlightedSymbols: string[]; + issueLocationsByLine: { [line: number]: LinearIssueLocation[] }; + issuePopup?: { issue: string; name: string }; + issues?: Issue[]; + issuesByLine: { [line: number]: Issue[] }; + loading: boolean; + loadingSourcesAfter: boolean; + loadingSourcesBefore: boolean; + notAccessible: boolean; + notExist: boolean; + openIssuesByLine: { [line: number]: boolean }; + selectedIssue?: string; + sourceRemoved: boolean; + sources?: SourceLine[]; + symbolsByLine: { [line: number]: string[] }; +} + +export default class SourceViewer extends React.PureComponent<Props, State> { + node?: HTMLElement | null; + mounted = false; + + static defaultProps = { + displayAllIssues: false, + displayIssueLocationsCount: true, + displayIssueLocationsLink: true, + displayLocationMarkers: true + }; + + constructor(props: Props) { + super(props); + + this.state = { + duplicationsByLine: {}, + hasSourcesAfter: false, + highlightedSymbols: [], + issuesByLine: {}, + issueLocationsByLine: {}, + loading: true, + loadingSourcesAfter: false, + loadingSourcesBefore: false, + notAccessible: false, + notExist: false, + openIssuesByLine: {}, + selectedIssue: props.selectedIssue, + sourceRemoved: false, + symbolsByLine: {} + }; + } + + componentDidMount() { + this.mounted = true; + this.fetchComponent(); + } + + async componentDidUpdate(prevProps: Props) { + if ( + this.props.onIssueSelect !== undefined && + this.props.selectedIssue !== prevProps.selectedIssue + ) { + this.setState({ selectedIssue: this.props.selectedIssue }); + } + if ( + prevProps.component !== this.props.component || + !isSameBranchLike(prevProps.branchLike, this.props.branchLike) + ) { + this.fetchComponent(); + } else if ( + this.props.aroundLine !== undefined && + prevProps.aroundLine !== this.props.aroundLine && + this.isLineOutsideOfRange(this.props.aroundLine) + ) { + const sources = await this.fetchSources().catch(() => []); + if (this.mounted) { + const finalSources = sources.slice(0, LINES_TO_LOAD); + this.setState( + { + sources: sources.slice(0, LINES_TO_LOAD), + hasSourcesAfter: sources.length > LINES_TO_LOAD + }, + () => { + if (this.props.onLoaded && this.state.component && this.state.issues) { + this.props.onLoaded(this.state.component, finalSources, this.state.issues); + } + } + ); + } + } else { + this.checkSelectedIssueChange(); + } + } + + componentWillUnmount() { + this.mounted = false; + } + + loadComponent(component: string, branchLike?: BranchLike) { + return Promise.all([ + getComponentForSourceViewer({ component, ...getBranchLikeQuery(branchLike) }), + getComponentData({ component, ...getBranchLikeQuery(branchLike) }) + ]).then(([sourceViewerComponent, { component }]) => ({ + ...sourceViewerComponent, + leakPeriodDate: component.leakPeriodDate + })); + } + + checkSelectedIssueChange() { + const { selectedIssue } = this.props; + const { issues } = this.state; + if ( + selectedIssue !== undefined && + issues !== undefined && + issues.find(issue => issue.key === selectedIssue) === undefined + ) { + this.reloadIssues(); + } + } + + loadSources( + key: string, + from: number | undefined, + to: number | undefined, + branchLike: BranchLike | undefined + ) { + return getSources({ key, from, to, ...getBranchLikeQuery(branchLike) }); + } + + get loadIssues() { + return this.props.loadIssues || defaultLoadIssues; + } + + computeCoverageStatus(lines: SourceLine[]) { + return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) })); + } + + isLineOutsideOfRange(lineNumber: number) { + const { sources } = this.state; + if (sources && sources.length > 0) { + const firstLine = sources[0]; + const lastList = sources[sources.length - 1]; + return lineNumber < firstLine.line || lineNumber > lastList.line; + } + + return true; + } + + fetchComponent() { + this.setState({ loading: true }); + + const to = (this.props.aroundLine || 0) + LINES_TO_LOAD; + const loadIssues = (component: SourceViewerFile, sources: SourceLine[]) => { + this.loadIssues(this.props.component, 1, to, this.props.branchLike).then( + issues => { + if (this.mounted) { + const finalSources = sources.slice(0, LINES_TO_LOAD); + this.setState( + { + component, + duplicatedFiles: undefined, + duplications: undefined, + duplicationsByLine: {}, + hasSourcesAfter: sources.length > LINES_TO_LOAD, + highlightedSymbols: [], + issueLocationsByLine: locationsByLine(issues), + issues, + issuesByLine: issuesByLine(issues), + loading: false, + notAccessible: false, + notExist: false, + openIssuesByLine: {}, + issuePopup: undefined, + sourceRemoved: false, + sources: this.computeCoverageStatus(finalSources), + symbolsByLine: symbolsByLine(sources.slice(0, LINES_TO_LOAD)) + }, + () => { + if (this.props.onLoaded) { + this.props.onLoaded(component, finalSources, issues); + } + } + ); + } + }, + () => { + /* no op */ + } + ); + }; + + const onFailLoadComponent = (response: Response) => { + if (this.mounted) { + if (response.status === HttpStatus.Forbidden) { + this.setState({ loading: false, notAccessible: true }); + } else if (response.status === HttpStatus.NotFound) { + this.setState({ loading: false, notExist: true }); + } + } + }; + + const onFailLoadSources = (response: Response, component: SourceViewerFile) => { + if (this.mounted) { + if (response.status === HttpStatus.Forbidden) { + this.setState({ component, loading: false, notAccessible: true }); + } else if (response.status === HttpStatus.NotFound) { + this.setState({ component, loading: false, sourceRemoved: true }); + } + } + }; + + const onResolve = (component: SourceViewerFile) => { + const sourcesRequest = + component.q === 'FIL' || component.q === 'UTS' ? this.fetchSources() : Promise.resolve([]); + sourcesRequest.then( + sources => loadIssues(component, sources), + response => onFailLoadSources(response, component) + ); + }; + + this.loadComponent(this.props.component, this.props.branchLike).then( + onResolve, + onFailLoadComponent + ); + } + + reloadIssues() { + if (!this.state.sources) { + return; + } + const firstSourceLine = this.state.sources[0]; + const lastSourceLine = this.state.sources[this.state.sources.length - 1]; + this.loadIssues( + this.props.component, + firstSourceLine && firstSourceLine.line, + lastSourceLine && lastSourceLine.line, + this.props.branchLike + ).then( + issues => { + if (this.mounted) { + this.setState({ + issues, + issuesByLine: issuesByLine(issues), + issueLocationsByLine: locationsByLine(issues) + }); + } + }, + () => { + /* no op */ + } + ); + } + + fetchSources = (): Promise<SourceLine[]> => { + return new Promise((resolve, reject) => { + const onFailLoadSources = (response: Response) => { + if (this.mounted) { + if ([HttpStatus.Forbidden, HttpStatus.NotFound].includes(response.status)) { + reject(response); + } else { + resolve([]); + } + } + }; + + const from = this.props.aroundLine + ? Math.max(1, this.props.aroundLine - LINES_TO_LOAD / 2 + 1) + : 1; + + let to = this.props.aroundLine + ? this.props.aroundLine + LINES_TO_LOAD / 2 + 1 + : LINES_TO_LOAD + 1; + // make sure we try to download `LINES` lines + if (from === 1 && to < LINES_TO_LOAD) { + to = LINES_TO_LOAD; + } + // request one additional line to define `hasSourcesAfter` + to++; + + this.loadSources(this.props.component, from, to, this.props.branchLike).then(sources => { + resolve(sources); + }, onFailLoadSources); + }); + }; + + loadSourcesBefore = () => { + if (!this.state.sources) { + return; + } + const firstSourceLine = this.state.sources[0]; + this.setState({ loadingSourcesBefore: true }); + const from = Math.max(1, firstSourceLine.line - LINES_TO_LOAD); + Promise.all([ + this.loadSources(this.props.component, from, firstSourceLine.line - 1, this.props.branchLike), + this.loadIssues(this.props.component, from, firstSourceLine.line - 1, this.props.branchLike) + ]).then( + ([sources, issues]) => { + if (this.mounted) { + this.setState(prevState => { + const nextIssues = uniqBy([...issues, ...(prevState.issues || [])], issue => issue.key); + return { + issues: nextIssues, + issuesByLine: issuesByLine(nextIssues), + issueLocationsByLine: locationsByLine(nextIssues), + loadingSourcesBefore: false, + sources: [...this.computeCoverageStatus(sources), ...(prevState.sources || [])], + symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) } + }; + }); + } + }, + () => { + /* no op */ + } + ); + }; + + loadSourcesAfter = () => { + if (!this.state.sources) { + return; + } + const lastSourceLine = this.state.sources[this.state.sources.length - 1]; + this.setState({ loadingSourcesAfter: true }); + const fromLine = lastSourceLine.line + 1; + // request one additional line to define `hasSourcesAfter` + const toLine = lastSourceLine.line + LINES_TO_LOAD + 1; + Promise.all([ + this.loadSources(this.props.component, fromLine, toLine, this.props.branchLike), + this.loadIssues(this.props.component, fromLine, toLine, this.props.branchLike) + ]).then( + ([sources, issues]) => { + if (this.mounted) { + this.setState(prevState => { + const nextIssues = uniqBy([...(prevState.issues || []), ...issues], issue => issue.key); + return { + issues: nextIssues, + issuesByLine: issuesByLine(nextIssues), + issueLocationsByLine: locationsByLine(nextIssues), + hasSourcesAfter: sources.length > LINES_TO_LOAD, + loadingSourcesAfter: false, + sources: [ + ...(prevState.sources || []), + ...this.computeCoverageStatus(sources.slice(0, LINES_TO_LOAD)) + ], + symbolsByLine: { + ...prevState.symbolsByLine, + ...symbolsByLine(sources.slice(0, LINES_TO_LOAD)) + } + }; + }); + } + }, + () => { + /* no op */ + } + ); + }; + + loadDuplications = () => { + getDuplications({ + key: this.props.component, + ...getBranchLikeQuery(this.props.branchLike) + }).then( + r => { + if (this.mounted) { + this.setState({ + duplications: r.duplications, + duplicationsByLine: duplicationsByLine(r.duplications), + duplicatedFiles: r.files + }); + } + }, + () => { + /* no op */ + } + ); + }; + + handleIssuePopupToggle = (issue: string, popupName: string, open?: boolean) => { + this.setState((state: State) => { + const samePopup = + state.issuePopup && state.issuePopup.name === popupName && state.issuePopup.issue === issue; + if (open !== false && !samePopup) { + return { issuePopup: { issue, name: popupName } }; + } else if (open !== true && samePopup) { + return { issuePopup: undefined }; + } + return null; + }); + }; + + handleSymbolClick = (symbols: string[]) => { + this.setState(state => { + const shouldDisable = intersection(state.highlightedSymbols, symbols).length > 0; + const highlightedSymbols = shouldDisable ? [] : symbols; + return { highlightedSymbols }; + }); + }; + + handleIssueSelect = (issue: string) => { + if (this.props.onIssueSelect) { + this.props.onIssueSelect(issue); + } else { + this.setState({ selectedIssue: issue }); + } + }; + + handleIssueUnselect = () => { + if (this.props.onIssueUnselect) { + this.props.onIssueUnselect(); + } else { + this.setState({ selectedIssue: undefined }); + } + }; + + handleOpenIssues = (line: SourceLine) => { + this.setState(state => ({ + openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true } + })); + }; + + handleCloseIssues = (line: SourceLine) => { + this.setState(state => ({ + openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false } + })); + }; + + handleIssueChange = (issue: Issue) => { + this.setState(({ issues = [] }) => { + const newIssues = issues.map(candidate => (candidate.key === issue.key ? issue : candidate)); + return { issues: newIssues, issuesByLine: issuesByLine(newIssues) }; + }); + if (this.props.onIssueChange) { + this.props.onIssueChange(issue); + } + }; + + renderDuplicationPopup = (index: number, line: number) => { + const { component, duplicatedFiles, duplications } = this.state; + + if (!component || !duplicatedFiles) { + return null; + } + + const blocks = getDuplicationBlocksForIndex(duplications, index); + + return ( + <WorkspaceContext.Consumer> + {({ openComponent }) => ( + <DuplicationPopup + blocks={filterDuplicationBlocksByLine(blocks, line)} + branchLike={this.props.branchLike} + duplicatedFiles={duplicatedFiles} + inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)} + openComponent={openComponent} + sourceViewerFile={component} + /> + )} + </WorkspaceContext.Consumer> + ); + }; + + renderCode(sources: SourceLine[]) { + const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; + return ( + <SourceViewerCode + branchLike={this.props.branchLike} + displayAllIssues={this.props.displayAllIssues} + displayIssueLocationsCount={this.props.displayIssueLocationsCount} + displayIssueLocationsLink={this.props.displayIssueLocationsLink} + displayLocationMarkers={this.props.displayLocationMarkers} + duplications={this.state.duplications} + duplicationsByLine={this.state.duplicationsByLine} + hasSourcesAfter={this.state.hasSourcesAfter} + hasSourcesBefore={hasSourcesBefore} + highlightedLine={this.props.highlightedLine} + highlightedLocationMessage={this.props.highlightedLocationMessage} + highlightedLocations={this.props.highlightedLocations} + highlightedSymbols={this.state.highlightedSymbols} + issueLocationsByLine={this.state.issueLocationsByLine} + issuePopup={this.state.issuePopup} + issues={this.state.issues} + issuesByLine={this.state.issuesByLine} + loadDuplications={this.loadDuplications} + loadSourcesAfter={this.loadSourcesAfter} + loadSourcesBefore={this.loadSourcesBefore} + loadingSourcesAfter={this.state.loadingSourcesAfter} + loadingSourcesBefore={this.state.loadingSourcesBefore} + onIssueChange={this.handleIssueChange} + onIssuePopupToggle={this.handleIssuePopupToggle} + onIssueSelect={this.handleIssueSelect} + onIssueUnselect={this.handleIssueUnselect} + onIssuesClose={this.handleCloseIssues} + onIssuesOpen={this.handleOpenIssues} + onLocationSelect={this.props.onLocationSelect} + onSymbolClick={this.handleSymbolClick} + openIssuesByLine={this.state.openIssuesByLine} + renderDuplicationPopup={this.renderDuplicationPopup} + scroll={this.props.scroll} + metricKey={this.props.metricKey} + selectedIssue={this.state.selectedIssue} + sources={sources} + symbolsByLine={this.state.symbolsByLine} + /> + ); + } + + renderHeader(branchLike: BranchLike | undefined, sourceViewerFile: SourceViewerFile) { + return this.props.slimHeader ? ( + <SourceViewerHeaderSlim branchLike={branchLike} sourceViewerFile={sourceViewerFile} /> + ) : ( + <WorkspaceContext.Consumer> + {({ openComponent }) => ( + <SourceViewerHeader + branchLike={this.props.branchLike} + componentMeasures={this.props.componentMeasures} + openComponent={openComponent} + showMeasures={this.props.showMeasures} + sourceViewerFile={sourceViewerFile} + /> + )} + </WorkspaceContext.Consumer> + ); + } + + render() { + const { component, loading, sources, notAccessible, sourceRemoved } = this.state; + + if (loading) { + return null; + } + + if (this.state.notExist) { + return ( + <Alert className="spacer-top" variant="warning"> + {translate('component_viewer.no_component')} + </Alert> + ); + } + + if (notAccessible) { + return ( + <Alert className="spacer-top" variant="warning"> + {translate('code_viewer.no_source_code_displayed_due_to_security')} + </Alert> + ); + } + + if (!component) { + return null; + } + + return ( + <SourceViewerContext.Provider value={{ branchLike: this.props.branchLike, file: component }}> + <div className="source-viewer" ref={node => (this.node = node)}> + {this.renderHeader(this.props.branchLike, component)} + {sourceRemoved && ( + <Alert className="spacer-top" variant="warning"> + {translate('code_viewer.no_source_code_displayed_due_to_source_removed')} + </Alert> + )} + {!sourceRemoved && sources !== undefined && this.renderCode(sources)} + </div> + </SourceViewerContext.Provider> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx deleted file mode 100644 index 93950d7fff7..00000000000 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewerBase.tsx +++ /dev/null @@ -1,673 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2022 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import { intersection, uniqBy } from 'lodash'; -import * as React from 'react'; -import { - getComponentData, - getComponentForSourceViewer, - getDuplications, - getSources -} from '../../api/components'; -import { Alert } from '../../components/ui/Alert'; -import { getBranchLikeQuery, isSameBranchLike } from '../../helpers/branch-like'; -import { translate } from '../../helpers/l10n'; -import { BranchLike } from '../../types/branch-like'; -import { - Dict, - DuplicatedFile, - Duplication, - FlowLocation, - Issue, - LinearIssueLocation, - Measure, - SourceLine, - SourceViewerFile -} from '../../types/types'; -import { WorkspaceContext } from '../workspace/context'; -import DuplicationPopup from './components/DuplicationPopup'; -import { - filterDuplicationBlocksByLine, - getDuplicationBlocksForIndex, - isDuplicationBlockInRemovedComponent -} from './helpers/duplications'; -import getCoverageStatus from './helpers/getCoverageStatus'; -import { - duplicationsByLine, - issuesByLine, - locationsByLine, - symbolsByLine -} from './helpers/indexing'; -import { LINES_TO_LOAD } from './helpers/lines'; -import defaultLoadIssues from './helpers/loadIssues'; -import SourceViewerCode from './SourceViewerCode'; -import { SourceViewerContext } from './SourceViewerContext'; -import SourceViewerHeader from './SourceViewerHeader'; -import SourceViewerHeaderSlim from './SourceViewerHeaderSlim'; -import './styles.css'; - -// TODO react-virtualized - -export interface Props { - aroundLine?: number; - branchLike: BranchLike | undefined; - component: string; - componentMeasures?: Measure[]; - displayAllIssues?: boolean; - displayIssueLocationsCount?: boolean; - displayIssueLocationsLink?: boolean; - displayLocationMarkers?: boolean; - highlightedLine?: number; - // `undefined` elements mean they are located in a different file, - // but kept to maintaint the location indexes - highlightedLocations?: (FlowLocation | undefined)[]; - highlightedLocationMessage?: { index: number; text: string | undefined }; - loadIssues?: ( - component: string, - from: number, - to: number, - branchLike: BranchLike | undefined - ) => Promise<Issue[]>; - onLoaded?: (component: SourceViewerFile, sources: SourceLine[], issues: Issue[]) => void; - onLocationSelect?: (index: number) => void; - onIssueChange?: (issue: Issue) => void; - onIssueSelect?: (issueKey: string) => void; - onIssueUnselect?: () => void; - scroll?: (element: HTMLElement) => void; - selectedIssue?: string; - showMeasures?: boolean; - metricKey?: string; - slimHeader?: boolean; -} - -interface State { - component?: SourceViewerFile; - duplicatedFiles?: Dict<DuplicatedFile>; - duplications?: Duplication[]; - duplicationsByLine: { [line: number]: number[] }; - hasSourcesAfter: boolean; - highlightedSymbols: string[]; - issueLocationsByLine: { [line: number]: LinearIssueLocation[] }; - issuePopup?: { issue: string; name: string }; - issues?: Issue[]; - issuesByLine: { [line: number]: Issue[] }; - loading: boolean; - loadingSourcesAfter: boolean; - loadingSourcesBefore: boolean; - notAccessible: boolean; - notExist: boolean; - openIssuesByLine: { [line: number]: boolean }; - selectedIssue?: string; - sourceRemoved: boolean; - sources?: SourceLine[]; - symbolsByLine: { [line: number]: string[] }; -} - -export default class SourceViewerBase extends React.PureComponent<Props, State> { - node?: HTMLElement | null; - mounted = false; - - static defaultProps = { - displayAllIssues: false, - displayIssueLocationsCount: true, - displayIssueLocationsLink: true, - displayLocationMarkers: true - }; - - constructor(props: Props) { - super(props); - - this.state = { - duplicationsByLine: {}, - hasSourcesAfter: false, - highlightedSymbols: [], - issuesByLine: {}, - issueLocationsByLine: {}, - loading: true, - loadingSourcesAfter: false, - loadingSourcesBefore: false, - notAccessible: false, - notExist: false, - openIssuesByLine: {}, - selectedIssue: props.selectedIssue, - sourceRemoved: false, - symbolsByLine: {} - }; - } - - componentDidMount() { - this.mounted = true; - this.fetchComponent(); - } - - componentDidUpdate(prevProps: Props) { - if ( - this.props.onIssueSelect !== undefined && - this.props.selectedIssue !== prevProps.selectedIssue - ) { - this.setState({ selectedIssue: this.props.selectedIssue }); - } - if ( - prevProps.component !== this.props.component || - !isSameBranchLike(prevProps.branchLike, this.props.branchLike) - ) { - this.fetchComponent(); - } else if ( - this.props.aroundLine !== undefined && - prevProps.aroundLine !== this.props.aroundLine && - this.isLineOutsideOfRange(this.props.aroundLine) - ) { - this.fetchSources().then( - sources => { - if (this.mounted) { - const finalSources = sources.slice(0, LINES_TO_LOAD); - this.setState( - { - sources: sources.slice(0, LINES_TO_LOAD), - hasSourcesAfter: sources.length > LINES_TO_LOAD - }, - () => { - if (this.props.onLoaded && this.state.component && this.state.issues) { - this.props.onLoaded(this.state.component, finalSources, this.state.issues); - } - } - ); - } - }, - () => { - // TODO - } - ); - } else { - const { selectedIssue } = this.props; - const { issues } = this.state; - if ( - selectedIssue !== undefined && - issues !== undefined && - issues.find(issue => issue.key === selectedIssue) === undefined - ) { - this.reloadIssues(); - } - } - } - - componentWillUnmount() { - this.mounted = false; - } - - loadComponent(component: string, branchLike?: BranchLike) { - return Promise.all([ - getComponentForSourceViewer({ component, ...getBranchLikeQuery(branchLike) }), - getComponentData({ component, ...getBranchLikeQuery(branchLike) }) - ]).then(([sourceViewerComponent, { component }]) => ({ - ...sourceViewerComponent, - leakPeriodDate: component.leakPeriodDate - })); - } - - loadSources( - key: string, - from: number | undefined, - to: number | undefined, - branchLike: BranchLike | undefined - ) { - return getSources({ key, from, to, ...getBranchLikeQuery(branchLike) }); - } - - get loadIssues() { - return this.props.loadIssues || defaultLoadIssues; - } - - computeCoverageStatus(lines: SourceLine[]) { - return lines.map(line => ({ ...line, coverageStatus: getCoverageStatus(line) })); - } - - isLineOutsideOfRange(lineNumber: number) { - const { sources } = this.state; - if (sources && sources.length > 0) { - const firstLine = sources[0]; - const lastList = sources[sources.length - 1]; - return lineNumber < firstLine.line || lineNumber > lastList.line; - } - - return true; - } - - fetchComponent() { - this.setState({ loading: true }); - - const to = (this.props.aroundLine || 0) + LINES_TO_LOAD; - const loadIssues = (component: SourceViewerFile, sources: SourceLine[]) => { - this.loadIssues(this.props.component, 1, to, this.props.branchLike).then( - issues => { - if (this.mounted) { - const finalSources = sources.slice(0, LINES_TO_LOAD); - this.setState( - { - component, - duplicatedFiles: undefined, - duplications: undefined, - duplicationsByLine: {}, - hasSourcesAfter: sources.length > LINES_TO_LOAD, - highlightedSymbols: [], - issueLocationsByLine: locationsByLine(issues), - issues, - issuesByLine: issuesByLine(issues), - loading: false, - notAccessible: false, - notExist: false, - openIssuesByLine: {}, - issuePopup: undefined, - sourceRemoved: false, - sources: this.computeCoverageStatus(finalSources), - symbolsByLine: symbolsByLine(sources.slice(0, LINES_TO_LOAD)) - }, - () => { - if (this.props.onLoaded) { - this.props.onLoaded(component, finalSources, issues); - } - } - ); - } - }, - () => { - // TODO - } - ); - }; - - const onFailLoadComponent = (response: Response) => { - // TODO handle other statuses - if (this.mounted) { - if (response.status === 403) { - this.setState({ loading: false, notAccessible: true }); - } else if (response.status === 404) { - this.setState({ loading: false, notExist: true }); - } - } - }; - - const onFailLoadSources = (response: Response, component: SourceViewerFile) => { - // TODO handle other statuses - if (this.mounted) { - if (response.status === 403) { - this.setState({ component, loading: false, notAccessible: true }); - } else if (response.status === 404) { - this.setState({ component, loading: false, sourceRemoved: true }); - } - } - }; - - const onResolve = (component: SourceViewerFile) => { - const sourcesRequest = - component.q === 'FIL' || component.q === 'UTS' ? this.fetchSources() : Promise.resolve([]); - sourcesRequest.then( - sources => loadIssues(component, sources), - response => onFailLoadSources(response, component) - ); - }; - - this.loadComponent(this.props.component, this.props.branchLike).then( - onResolve, - onFailLoadComponent - ); - } - - reloadIssues() { - if (!this.state.sources) { - return; - } - const firstSourceLine = this.state.sources[0]; - const lastSourceLine = this.state.sources[this.state.sources.length - 1]; - this.loadIssues( - this.props.component, - firstSourceLine && firstSourceLine.line, - lastSourceLine && lastSourceLine.line, - this.props.branchLike - ).then( - issues => { - if (this.mounted) { - this.setState({ - issues, - issuesByLine: issuesByLine(issues), - issueLocationsByLine: locationsByLine(issues) - }); - } - }, - () => { - // TODO - } - ); - } - - fetchSources = (): Promise<SourceLine[]> => { - return new Promise((resolve, reject) => { - const onFailLoadSources = (response: Response) => { - // TODO handle other statuses - if (this.mounted) { - if ([403, 404].includes(response.status)) { - reject(response); - } else { - resolve([]); - } - } - }; - - const from = this.props.aroundLine - ? Math.max(1, this.props.aroundLine - LINES_TO_LOAD / 2 + 1) - : 1; - - let to = this.props.aroundLine - ? this.props.aroundLine + LINES_TO_LOAD / 2 + 1 - : LINES_TO_LOAD + 1; - // make sure we try to download `LINES` lines - if (from === 1 && to < LINES_TO_LOAD) { - to = LINES_TO_LOAD; - } - // request one additional line to define `hasSourcesAfter` - to++; - - this.loadSources(this.props.component, from, to, this.props.branchLike).then(sources => { - resolve(sources); - }, onFailLoadSources); - }); - }; - - loadSourcesBefore = () => { - if (!this.state.sources) { - return; - } - const firstSourceLine = this.state.sources[0]; - this.setState({ loadingSourcesBefore: true }); - const from = Math.max(1, firstSourceLine.line - LINES_TO_LOAD); - Promise.all([ - this.loadSources(this.props.component, from, firstSourceLine.line - 1, this.props.branchLike), - this.loadIssues(this.props.component, from, firstSourceLine.line - 1, this.props.branchLike) - ]).then( - ([sources, issues]) => { - if (this.mounted) { - this.setState(prevState => { - const nextIssues = uniqBy([...issues, ...(prevState.issues || [])], issue => issue.key); - return { - issues: nextIssues, - issuesByLine: issuesByLine(nextIssues), - issueLocationsByLine: locationsByLine(nextIssues), - loadingSourcesBefore: false, - sources: [...this.computeCoverageStatus(sources), ...(prevState.sources || [])], - symbolsByLine: { ...prevState.symbolsByLine, ...symbolsByLine(sources) } - }; - }); - } - }, - () => { - // TODO - } - ); - }; - - loadSourcesAfter = () => { - if (!this.state.sources) { - return; - } - const lastSourceLine = this.state.sources[this.state.sources.length - 1]; - this.setState({ loadingSourcesAfter: true }); - const fromLine = lastSourceLine.line + 1; - // request one additional line to define `hasSourcesAfter` - const toLine = lastSourceLine.line + LINES_TO_LOAD + 1; - Promise.all([ - this.loadSources(this.props.component, fromLine, toLine, this.props.branchLike), - this.loadIssues(this.props.component, fromLine, toLine, this.props.branchLike) - ]).then( - ([sources, issues]) => { - if (this.mounted) { - this.setState(prevState => { - const nextIssues = uniqBy([...(prevState.issues || []), ...issues], issue => issue.key); - return { - issues: nextIssues, - issuesByLine: issuesByLine(nextIssues), - issueLocationsByLine: locationsByLine(nextIssues), - hasSourcesAfter: sources.length > LINES_TO_LOAD, - loadingSourcesAfter: false, - sources: [ - ...(prevState.sources || []), - ...this.computeCoverageStatus(sources.slice(0, LINES_TO_LOAD)) - ], - symbolsByLine: { - ...prevState.symbolsByLine, - ...symbolsByLine(sources.slice(0, LINES_TO_LOAD)) - } - }; - }); - } - }, - () => { - // TODO - } - ); - }; - - loadDuplications = () => { - getDuplications({ - key: this.props.component, - ...getBranchLikeQuery(this.props.branchLike) - }).then( - r => { - if (this.mounted) { - this.setState({ - duplications: r.duplications, - duplicationsByLine: duplicationsByLine(r.duplications), - duplicatedFiles: r.files - }); - } - }, - () => { - // TODO - } - ); - }; - - handleIssuePopupToggle = (issue: string, popupName: string, open?: boolean) => { - this.setState((state: State) => { - const samePopup = - state.issuePopup && state.issuePopup.name === popupName && state.issuePopup.issue === issue; - if (open !== false && !samePopup) { - return { issuePopup: { issue, name: popupName } }; - } else if (open !== true && samePopup) { - return { issuePopup: undefined }; - } - return null; - }); - }; - - handleSymbolClick = (symbols: string[]) => { - this.setState(state => { - const shouldDisable = intersection(state.highlightedSymbols, symbols).length > 0; - const highlightedSymbols = shouldDisable ? [] : symbols; - return { highlightedSymbols }; - }); - }; - - handleIssueSelect = (issue: string) => { - if (this.props.onIssueSelect) { - this.props.onIssueSelect(issue); - } else { - this.setState({ selectedIssue: issue }); - } - }; - - handleIssueUnselect = () => { - if (this.props.onIssueUnselect) { - this.props.onIssueUnselect(); - } else { - this.setState({ selectedIssue: undefined }); - } - }; - - handleOpenIssues = (line: SourceLine) => { - this.setState(state => ({ - openIssuesByLine: { ...state.openIssuesByLine, [line.line]: true } - })); - }; - - handleCloseIssues = (line: SourceLine) => { - this.setState(state => ({ - openIssuesByLine: { ...state.openIssuesByLine, [line.line]: false } - })); - }; - - handleIssueChange = (issue: Issue) => { - this.setState(({ issues = [] }) => { - const newIssues = issues.map(candidate => (candidate.key === issue.key ? issue : candidate)); - return { issues: newIssues, issuesByLine: issuesByLine(newIssues) }; - }); - if (this.props.onIssueChange) { - this.props.onIssueChange(issue); - } - }; - - renderDuplicationPopup = (index: number, line: number) => { - const { component, duplicatedFiles, duplications } = this.state; - - if (!component || !duplicatedFiles) { - return null; - } - - const blocks = getDuplicationBlocksForIndex(duplications, index); - - return ( - <WorkspaceContext.Consumer> - {({ openComponent }) => ( - <DuplicationPopup - blocks={filterDuplicationBlocksByLine(blocks, line)} - branchLike={this.props.branchLike} - duplicatedFiles={duplicatedFiles} - inRemovedComponent={isDuplicationBlockInRemovedComponent(blocks)} - openComponent={openComponent} - sourceViewerFile={component} - /> - )} - </WorkspaceContext.Consumer> - ); - }; - - renderCode(sources: SourceLine[]) { - const hasSourcesBefore = sources.length > 0 && sources[0].line > 1; - return ( - <SourceViewerCode - branchLike={this.props.branchLike} - displayAllIssues={this.props.displayAllIssues} - displayIssueLocationsCount={this.props.displayIssueLocationsCount} - displayIssueLocationsLink={this.props.displayIssueLocationsLink} - displayLocationMarkers={this.props.displayLocationMarkers} - duplications={this.state.duplications} - duplicationsByLine={this.state.duplicationsByLine} - hasSourcesAfter={this.state.hasSourcesAfter} - hasSourcesBefore={hasSourcesBefore} - highlightedLine={this.props.highlightedLine} - highlightedLocationMessage={this.props.highlightedLocationMessage} - highlightedLocations={this.props.highlightedLocations} - highlightedSymbols={this.state.highlightedSymbols} - issueLocationsByLine={this.state.issueLocationsByLine} - issuePopup={this.state.issuePopup} - issues={this.state.issues} - issuesByLine={this.state.issuesByLine} - loadDuplications={this.loadDuplications} - loadSourcesAfter={this.loadSourcesAfter} - loadSourcesBefore={this.loadSourcesBefore} - loadingSourcesAfter={this.state.loadingSourcesAfter} - loadingSourcesBefore={this.state.loadingSourcesBefore} - onIssueChange={this.handleIssueChange} - onIssuePopupToggle={this.handleIssuePopupToggle} - onIssueSelect={this.handleIssueSelect} - onIssueUnselect={this.handleIssueUnselect} - onIssuesClose={this.handleCloseIssues} - onIssuesOpen={this.handleOpenIssues} - onLocationSelect={this.props.onLocationSelect} - onSymbolClick={this.handleSymbolClick} - openIssuesByLine={this.state.openIssuesByLine} - renderDuplicationPopup={this.renderDuplicationPopup} - scroll={this.props.scroll} - metricKey={this.props.metricKey} - selectedIssue={this.state.selectedIssue} - sources={sources} - symbolsByLine={this.state.symbolsByLine} - /> - ); - } - - renderHeader(branchLike: BranchLike | undefined, sourceViewerFile: SourceViewerFile) { - return this.props.slimHeader ? ( - <SourceViewerHeaderSlim branchLike={branchLike} sourceViewerFile={sourceViewerFile} /> - ) : ( - <WorkspaceContext.Consumer> - {({ openComponent }) => ( - <SourceViewerHeader - branchLike={this.props.branchLike} - componentMeasures={this.props.componentMeasures} - openComponent={openComponent} - showMeasures={this.props.showMeasures} - sourceViewerFile={sourceViewerFile} - /> - )} - </WorkspaceContext.Consumer> - ); - } - - render() { - const { component, loading, sources, notAccessible, sourceRemoved } = this.state; - - if (loading) { - return null; - } - - if (this.state.notExist) { - return ( - <Alert className="spacer-top" variant="warning"> - {translate('component_viewer.no_component')} - </Alert> - ); - } - - if (notAccessible) { - return ( - <Alert className="spacer-top" variant="warning"> - {translate('code_viewer.no_source_code_displayed_due_to_security')} - </Alert> - ); - } - - if (!component) { - return null; - } - - return ( - <SourceViewerContext.Provider value={{ branchLike: this.props.branchLike, file: component }}> - <div className="source-viewer" ref={node => (this.node = node)}> - {this.renderHeader(this.props.branchLike, component)} - {sourceRemoved && ( - <Alert className="spacer-top" variant="warning"> - {translate('code_viewer.no_source_code_displayed_due_to_source_removed')} - </Alert> - )} - {!sourceRemoved && sources !== undefined && this.renderCode(sources)} - </div> - </SourceViewerContext.Provider> - ); - } -} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx index c51ecb927c6..e8a35ab3ad9 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx @@ -21,12 +21,13 @@ import { queryHelpers, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { SourceViewerServiceMock } from '../../../api/mocks/SourceViewerServiceMock'; +import { HttpStatus } from '../../../helpers/request'; import { mockIssue } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; import SourceViewer from '../SourceViewer'; -import SourceViewerBase from '../SourceViewerBase'; jest.mock('../../../api/components'); +jest.mock('../../../api/issues'); jest.mock('../helpers/lines', () => { const lines = jest.requireActual('../helpers/lines'); return { @@ -37,6 +38,10 @@ jest.mock('../helpers/lines', () => { const handler = new SourceViewerServiceMock(); +beforeEach(() => { + handler.reset(); +}); + it('should show a permalink on line number', async () => { const user = userEvent.setup(); renderSourceViewer(); @@ -108,6 +113,53 @@ it('should show issue on empty file', async () => { expect(await screen.findByRole('row', { name: 'First Issue' })).toBeInTheDocument(); }); +it('should be able to interact with issue action', async () => { + const user = userEvent.setup(); + renderSourceViewer({ + loadIssues: jest.fn().mockResolvedValue([ + mockIssue(false, { + actions: ['set_type', 'set_tags', 'comment', 'set_severity', 'assign'], + key: 'first-issue', + message: 'First Issue', + line: 1, + textRange: { startLine: 1, endLine: 1, startOffset: 0, endOffset: 1 } + }) + ]) + }); + + //Open Issue type + await user.click( + await screen.findByRole('button', { name: 'issue.type.type_x_click_to_change.issue.type.BUG' }) + ); + expect(screen.getByRole('link', { name: 'issue.type.CODE_SMELL' })).toBeInTheDocument(); + + // Open severity + await user.click( + await screen.findByRole('button', { + name: 'issue.severity.severity_x_click_to_change.severity.MAJOR' + }) + ); + expect(screen.getByRole('link', { name: 'severity.MINOR' })).toBeInTheDocument(); + + // Close + await user.keyboard('{Escape}'); + expect(screen.queryByRole('link', { name: 'severity.MINOR' })).not.toBeInTheDocument(); + + // Change the severity + await user.click( + await screen.findByRole('button', { + name: 'issue.severity.severity_x_click_to_change.severity.MAJOR' + }) + ); + expect(screen.getByRole('link', { name: 'severity.MINOR' })).toBeInTheDocument(); + await user.click(screen.getByRole('link', { name: 'severity.MINOR' })); + expect( + screen.getByRole('button', { + name: 'issue.severity.severity_x_click_to_change.severity.MINOR' + }) + ).toBeInTheDocument(); +}); + it('should load line when looking arround unloaded line', async () => { const { rerender } = renderSourceViewer({ aroundLine: 50, @@ -299,11 +351,37 @@ it('should show duplication block', async () => { expect(duplicateLine.queryByRole('link', { name: 'test2.js' })).not.toBeInTheDocument(); }); -function renderSourceViewer(override?: Partial<SourceViewerBase['props']>) { +it('should highlight symbol', async () => { + const user = userEvent.setup(); + renderSourceViewer({ component: 'project:testSymb.tsx' }); + const symbols = await screen.findAllByText('symbole'); + await user.click(symbols[0]); + + // For now just check the class. Maybe found a better accessible way of showing higlighted symbole + symbols.forEach(element => { + expect(element).toHaveClass('highlighted'); + }); +}); + +it('should show correct message when component is not asscessible', async () => { + handler.setFailLoadingComponentStatus(HttpStatus.Forbidden); + renderSourceViewer(); + expect( + await screen.findByText('code_viewer.no_source_code_displayed_due_to_security') + ).toBeInTheDocument(); +}); + +it('should show correct message when component does not exist', async () => { + handler.setFailLoadingComponentStatus(HttpStatus.NotFound); + renderSourceViewer(); + expect(await screen.findByText('component_viewer.no_component')).toBeInTheDocument(); +}); + +function renderSourceViewer(override?: Partial<SourceViewer['props']>) { return renderComponent(getSourceViewerUi(override)); } -function getSourceViewerUi(override?: Partial<SourceViewerBase['props']>) { +function getSourceViewerUi(override?: Partial<SourceViewer['props']>) { return ( <SourceViewer aroundLine={1} diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewerBase-test.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-test.tsx index 66cb69fdb7e..ac622f4052e 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewerBase-test.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-test.tsx @@ -25,7 +25,7 @@ import { mockSourceLine, mockSourceViewerFile } from '../../../helpers/mocks/sou import { mockIssue } from '../../../helpers/testMocks'; import { waitAndUpdate } from '../../../helpers/testUtils'; import defaultLoadIssues from '../helpers/loadIssues'; -import SourceViewerBase from '../SourceViewerBase'; +import SourceViewer from '../SourceViewer'; jest.mock('../helpers/loadIssues', () => jest.fn().mockRejectedValue({})); @@ -148,8 +148,8 @@ it('should handle no sources when checking ranges', () => { expect(wrapper.instance().isLineOutsideOfRange(12)).toBe(true); }); -function shallowRender(overrides: Partial<SourceViewerBase['props']> = {}) { - return shallow<SourceViewerBase>( - <SourceViewerBase branchLike={mockMainBranch()} component="my-component" {...overrides} /> +function shallowRender(overrides: Partial<SourceViewer['props']> = {}) { + return shallow<SourceViewer>( + <SourceViewer branchLike={mockMainBranch()} component="my-component" {...overrides} /> ); } diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewerBase-test.tsx.snap b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewer-test.tsx.snap index ef8cdb4d1ba..ef8cdb4d1ba 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewerBase-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/__snapshots__/SourceViewer-test.tsx.snap |