diff options
author | Revanshu Paliwal <revanshu.paliwal@sonarsource.com> | 2024-08-28 16:40:16 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-09-04 20:03:11 +0000 |
commit | 0996c6186cfeb8e2c763d2f174b26ab276b232f7 (patch) | |
tree | 6a56285befb26ce1b41d9065ab86379cb91d31ac /server/sonar-web/src/main/js/components | |
parent | bed8af05f0c3f2b4c6018634d3d7aaba5b45a83c (diff) | |
download | sonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.tar.gz sonarqube-0996c6186cfeb8e2c763d2f174b26ab276b232f7.zip |
CODEFIX-12 Show new suggestion feature in issues page
Diffstat (limited to 'server/sonar-web/src/main/js/components')
5 files changed, 509 insertions, 8 deletions
diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx new file mode 100644 index 00000000000..482ebc1ab19 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx @@ -0,0 +1,91 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 { Button, ButtonVariety } from '@sonarsource/echoes-react'; +import { InProgressVisual, OverviewQGNotComputedIcon, OverviewQGPassedIcon } from 'design-system'; +import React from 'react'; +import { translate } from '../../helpers/l10n'; +import { usePrefetchSuggestion, useUnifiedSuggestionsQuery } from '../../queries/fix-suggestions'; +import { useRawSourceQuery } from '../../queries/sources'; +import { getBranchLikeQuery } from '../../sonar-aligned/helpers/branch-like'; +import { BranchLike } from '../../types/branch-like'; +import { Issue } from '../../types/types'; +import { IssueSuggestionFileSnippet } from './IssueSuggestionFileSnippet'; + +interface Props { + branchLike?: BranchLike; + issue: Issue; + language?: string; +} + +export function IssueSuggestionCodeTab({ branchLike, issue, language }: Readonly<Props>) { + const prefetchSuggestion = usePrefetchSuggestion(issue.key); + const { isPending, isLoading, isError, refetch } = useUnifiedSuggestionsQuery(issue, false); + const { isError: isIssueRawError } = useRawSourceQuery({ + ...getBranchLikeQuery(branchLike), + key: issue.component, + }); + + return ( + <> + {isPending && !isLoading && !isError && ( + <div className="sw-flex sw-flex-col sw-items-center"> + <OverviewQGPassedIcon className="sw-mt-6" /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.let_us_suggest_fix')} + </p> + <Button + className="sw-mt-4" + onClick={() => prefetchSuggestion()} + variety={ButtonVariety.Primary} + > + {translate('issues.code_fix.get_a_fix_suggestion')} + </Button> + </div> + )} + {isLoading && ( + <div className="sw-flex sw-pt-6 sw-flex-col sw-items-center"> + <InProgressVisual /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.fix_is_being_generated')} + </p> + </div> + )} + {isError && ( + <div className="sw-flex sw-flex-col sw-items-center"> + <OverviewQGNotComputedIcon className="sw-mt-6" /> + <p className="sw-body-sm-highlight sw-mt-4"> + {translate('issues.code_fix.something_went_wrong')} + </p> + <p className="sw-my-4">{translate('issues.code_fix.not_able_to_generate_fix')}</p> + {translate('issues.code_fix.check_how_to_fix')} + {!isIssueRawError && ( + <Button className="sw-mt-4" onClick={() => refetch()} variety={ButtonVariety.Primary}> + {translate('issues.code_fix.get_a_fix_suggestion')} + </Button> + )} + </div> + )} + + {!isPending && !isError && ( + <IssueSuggestionFileSnippet branchLike={branchLike} issue={issue} language={language} /> + )} + </> + ); +} diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx new file mode 100644 index 00000000000..731e854c239 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx @@ -0,0 +1,214 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 styled from '@emotion/styled'; +import { max } from 'lodash'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; + +import { + ClipboardIconButton, + CodeEllipsisDirection, + CodeEllipsisIcon, + LineCodeEllipsisStyled, + SonarCodeColorizer, + themeColor, +} from 'design-system'; +import { IssueSourceViewerHeader } from '../../apps/issues/crossComponentSourceViewer/IssueSourceViewerHeader'; +import { translate } from '../../helpers/l10n'; +import { useComponentForSourceViewer } from '../../queries/component'; +import { + DisplayedLine, + LineTypeEnum, + useUnifiedSuggestionsQuery, +} from '../../queries/fix-suggestions'; +import { BranchLike } from '../../types/branch-like'; +import { Issue } from '../../types/types'; +import { IssueSuggestionLine } from './IssueSuggestionLine'; + +interface Props { + branchLike?: BranchLike; + issue: Issue; + language?: string; +} +const EXPAND_SIZE = 10; +const BUFFER_CODE = 3; + +export function IssueSuggestionFileSnippet({ branchLike, issue, language }: Readonly<Props>) { + const [displayedLine, setDisplayedLine] = useState<DisplayedLine[]>([]); + + const { data: suggestion } = useUnifiedSuggestionsQuery(issue); + + const { data: sourceViewerFile } = useComponentForSourceViewer(issue.component, branchLike); + + useEffect(() => { + if (suggestion !== undefined) { + setDisplayedLine( + suggestion.unifiedLines.filter((line, index) => { + if (line.type !== LineTypeEnum.CODE) { + return true; + } + return suggestion.unifiedLines + .slice(max([index - BUFFER_CODE, 0]), index + BUFFER_CODE + 1) + .some((line) => line.type !== LineTypeEnum.CODE); + }), + ); + } + }, [suggestion]); + + const handleExpand = useCallback( + (index: number | undefined, at: number | undefined, to: number) => { + if (suggestion !== undefined) { + setDisplayedLine((current) => { + return [ + ...current.slice(0, index), + ...suggestion.unifiedLines.filter( + (line) => at !== undefined && at <= line.lineBefore && line.lineBefore < to, + ), + ...current.slice(index), + ]; + }); + } + }, + [suggestion], + ); + + if (suggestion === undefined) { + return null; + } + + return ( + <div> + {sourceViewerFile && ( + <IssueSourceViewerHeader + issueKey={issue.key} + sourceViewerFile={sourceViewerFile} + shouldShowOpenInIde={false} + shouldShowViewAllIssues={false} + /> + )} + <SourceFileWrapper className="js-source-file sw-mb-4"> + <SonarCodeColorizer> + {displayedLine[0]?.lineBefore !== 1 && ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + 0, + max([displayedLine[0].lineBefore - EXPAND_SIZE, 0]), + displayedLine[0].lineBefore, + ) + } + style={{ borderTop: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} /> + </LineCodeEllipsisStyled> + )} + {displayedLine.map((line, index) => ( + <Fragment key={`${line.lineBefore} -> ${line.lineAfter} `}> + {displayedLine[index - 1] !== undefined && + displayedLine[index - 1].lineBefore !== -1 && + line.lineBefore !== -1 && + displayedLine[index - 1].lineBefore !== line.lineBefore - 1 && ( + <> + {line.lineBefore - displayedLine[index - 1].lineBefore > EXPAND_SIZE ? ( + <> + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + displayedLine[index - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} /> + </LineCodeEllipsisStyled> + <LineCodeEllipsisStyled + onClick={() => + handleExpand(index, line.lineBefore - EXPAND_SIZE, line.lineBefore) + } + style={{ borderTop: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Up} /> + </LineCodeEllipsisStyled> + </> + ) : ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + line.lineBefore, + ) + } + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Middle} /> + </LineCodeEllipsisStyled> + )} + </> + )} + <div className="sw-relative"> + {line.copy !== undefined && ( + <StyledClipboardIconButton + aria-label={translate('component_viewer.copy_path_to_clipboard')} + copyValue={line.copy} + /> + )} + <IssueSuggestionLine + language={language} + line={line.code} + lineAfter={line.lineAfter} + lineBefore={line.lineBefore} + type={line.type} + /> + </div> + </Fragment> + ))} + + {displayedLine[displayedLine.length - 1]?.lineBefore !== + suggestion.unifiedLines[suggestion.unifiedLines.length - 1]?.lineBefore && ( + <LineCodeEllipsisStyled + onClick={() => + handleExpand( + displayedLine.length, + displayedLine[displayedLine.length - 1].lineBefore + 1, + displayedLine[displayedLine.length - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + style={{ borderBottom: 'none' }} + > + <CodeEllipsisIcon direction={CodeEllipsisDirection.Down} /> + </LineCodeEllipsisStyled> + )} + </SonarCodeColorizer> + </SourceFileWrapper> + <p className="sw-mt-4">{suggestion.explanation}</p> + </div> + ); +} + +const StyledClipboardIconButton = styled(ClipboardIconButton)` + position: absolute; + right: 4px; + top: -4px; + z-index: 9; +`; + +const SourceFileWrapper = styled.div` + border: 1px solid ${themeColor('codeLineBorder')}; +`; diff --git a/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx new file mode 100644 index 00000000000..d2cecf9b809 --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx @@ -0,0 +1,145 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 styled from '@emotion/styled'; +import { + CodeSyntaxHighlighter, + LineMeta, + LineStyled, + SuggestedLineWrapper, + themeBorder, + themeColor, +} from 'design-system'; +import React from 'react'; +import { LineTypeEnum } from '../../queries/fix-suggestions'; + +type LineType = 'code' | 'added' | 'removed'; + +export function IssueSuggestionLine({ + language, + line, + lineAfter, + lineBefore, + type = 'code', +}: Readonly<{ + language?: string; + line: string; + lineAfter: number; + lineBefore: number; + type: LineType; +}>) { + return ( + <SuggestedLineWrapper> + <LineMeta as="div"> + {type !== LineTypeEnum.ADDED && ( + <LineNumberStyled className="sw-px-1 sw-inline-block">{lineBefore}</LineNumberStyled> + )} + </LineMeta> + <LineMeta as="div"> + {type !== LineTypeEnum.REMOVED && ( + <LineNumberStyled className="sw-px-1 sw-inline-block">{lineAfter}</LineNumberStyled> + )} + </LineMeta> + <LineDirectionMeta as="div"> + {type === LineTypeEnum.REMOVED && ( + <RemovedLineLayer className="sw-px-2">-</RemovedLineLayer> + )} + {type === LineTypeEnum.ADDED && <AddedLineLayer className="sw-px-2">+</AddedLineLayer>} + </LineDirectionMeta> + <LineCodeLayers> + {type === LineTypeEnum.CODE && ( + <LineCodeLayer className="sw-px-3"> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodeLayer> + )} + {type === LineTypeEnum.REMOVED && ( + <RemovedLineLayer className="sw-px-3"> + <LineCodePreFormatted> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodePreFormatted> + </RemovedLineLayer> + )} + {type === LineTypeEnum.ADDED && ( + <AddedLineLayer className="sw-px-3"> + <LineCodePreFormatted> + <CodeSyntaxHighlighter + htmlAsString={`<pre style="white-space: pre-wrap;">${line}</pre>`} + language={language} + escapeDom={false} + /> + </LineCodePreFormatted> + </AddedLineLayer> + )} + </LineCodeLayers> + </SuggestedLineWrapper> + ); +} + +const LineNumberStyled = styled.div` + &:hover { + color: ${themeColor('codeLineMetaHover')}; + } + + &:focus-visible { + outline-offset: -1px; + } +`; + +const LineCodeLayers = styled.div` + position: relative; + display: grid; + height: 100%; + background-color: var(--line-background); + + ${LineStyled}:hover & { + background-color: ${themeColor('codeLineHover')}; + } +`; + +const LineDirectionMeta = styled(LineMeta)` + border-left: ${themeBorder('default', 'codeLineBorder')}; +`; + +const LineCodeLayer = styled.div` + grid-row: 1; + grid-column: 1; +`; + +const LineCodePreFormatted = styled.pre` + position: relative; + white-space: pre-wrap; + overflow-wrap: anywhere; + tab-size: 4; +`; + +const AddedLineLayer = styled.div` + background-color: ${themeColor('codeLineCoveredUnderline')}; +`; + +const RemovedLineLayer = styled.div` + background-color: ${themeColor('codeLineUncoveredUnderline')}; +`; diff --git a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx index c1ccaa506e1..fa4fff30c06 100644 --- a/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx +++ b/server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx @@ -23,6 +23,7 @@ import { cloneDeep, debounce, groupBy } from 'lodash'; import * as React from 'react'; import { Location } from 'react-router-dom'; import { dismissNotice } from '../../api/users'; +import withAvailableFeatures from '../../app/components/available-features/withAvailableFeatures'; import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext'; import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; import { RuleDescriptionSections } from '../../apps/coding-rules/rule'; @@ -31,17 +32,21 @@ import IssueHeader from '../../apps/issues/components/IssueHeader'; import StyledHeader from '../../apps/issues/components/StyledHeader'; import { fillBranchLike } from '../../helpers/branch-like'; import { translate } from '../../helpers/l10n'; +import { Feature } from '../../types/features'; import { Issue, RuleDetails } from '../../types/types'; -import { NoticeType } from '../../types/users'; +import { CurrentUser, NoticeType } from '../../types/users'; import ScreenPositionHelper from '../common/ScreenPositionHelper'; import withLocation from '../hoc/withLocation'; import MoreInfoRuleDescription from './MoreInfoRuleDescription'; import RuleDescription from './RuleDescription'; +import { TabSelectorContext } from './TabSelectorContext'; interface IssueTabViewerProps extends CurrentUserContextInterface { activityTabContent?: React.ReactNode; codeTabContent?: React.ReactNode; + currentUser: CurrentUser; extendedDescription?: string; + hasFeature: (feature: string) => boolean; issue: Issue; location: Location; onIssueChange: (issue: Issue) => void; @@ -49,6 +54,7 @@ interface IssueTabViewerProps extends CurrentUserContextInterface { ruleDetails: RuleDetails; selectedFlowIndex?: number; selectedLocationIndex?: number; + suggestionTabContent?: React.ReactNode; } interface State { displayEducationalPrinciplesNotification?: boolean; @@ -70,6 +76,7 @@ export enum TabKeys { WhyIsThisAnIssue = 'why', HowToFixIt = 'how_to_fix', AssessTheIssue = 'assess_the_problem', + CodeFix = 'code_fix', Activity = 'activity', MoreInfo = 'more_info', } @@ -127,7 +134,7 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey || prevProps.issue !== issue || prevProps.selectedFlowIndex !== selectedFlowIndex || - prevProps.selectedLocationIndex !== selectedLocationIndex || + (prevProps.selectedLocationIndex ?? -1) !== (selectedLocationIndex ?? -1) || prevProps.currentUser !== currentUser ) { this.setState((pState) => @@ -172,9 +179,12 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta const tabs = this.computeTabs(displayEducationalPrinciplesNotification); + const selectedTab = + resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab; + return { tabs, - selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab, + selectedTab, displayEducationalPrinciplesNotification, }; }; @@ -182,11 +192,14 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta computeTabs = (displayEducationalPrinciplesNotification: boolean) => { const { codeTabContent, + currentUser: { isLoggedIn }, ruleDetails: { descriptionSections, educationPrinciples, lang: ruleLanguage, type: ruleType }, ruleDescriptionContextKey, extendedDescription, activityTabContent, issue, + suggestionTabContent, + hasFeature, } = this.props; // As we might tamper with the description later on, we clone to avoid any side effect @@ -253,6 +266,16 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta /> ), }, + ...(hasFeature(Feature.FixSuggestions) && isLoggedIn + ? [ + { + value: TabKeys.CodeFix, + key: TabKeys.CodeFix, + label: translate('coding_rules.description_section.title', TabKeys.CodeFix), + content: suggestionTabContent, + }, + ] + : []), { value: TabKeys.Activity, key: TabKeys.Activity, @@ -330,9 +353,11 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta }; handleSelectTabs = (currentTabKey: TabKeys) => { - this.setState(({ tabs }) => ({ - selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0], - })); + this.setState(({ tabs }) => { + return { + selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0], + }; + }); }; render() { @@ -390,7 +415,9 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta })} key={tab.key} > - {tab.content} + <TabSelectorContext.Provider value={this.handleSelectTabs}> + {tab.content} + </TabSelectorContext.Provider> </div> )) } @@ -402,4 +429,4 @@ export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, Sta } } -export default withCurrentUserContext(withLocation(IssueTabViewer)); +export default withCurrentUserContext(withLocation(withAvailableFeatures(IssueTabViewer))); diff --git a/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts new file mode 100644 index 00000000000..5f1a4d6e90e --- /dev/null +++ b/server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts @@ -0,0 +1,24 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 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 { noop } from 'lodash'; +import { createContext } from 'react'; +import { TabKeys } from './IssueTabViewer'; + +export const TabSelectorContext = createContext<(selectedTab: TabKeys) => void>(noop); |