From 0996c6186cfeb8e2c763d2f174b26ab276b232f7 Mon Sep 17 00:00:00 2001 From: Revanshu Paliwal Date: Wed, 28 Aug 2024 16:40:16 +0200 Subject: CODEFIX-12 Show new suggestion feature in issues page --- .../js/components/rules/IssueSuggestionCodeTab.tsx | 91 +++++++++ .../rules/IssueSuggestionFileSnippet.tsx | 214 +++++++++++++++++++++ .../js/components/rules/IssueSuggestionLine.tsx | 145 ++++++++++++++ .../main/js/components/rules/IssueTabViewer.tsx | 43 ++++- .../main/js/components/rules/TabSelectorContext.ts | 24 +++ 5 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx create mode 100644 server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts (limited to 'server/sonar-web/src/main/js/components') 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) { + 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 && ( +
+ +

+ {translate('issues.code_fix.let_us_suggest_fix')} +

+ +
+ )} + {isLoading && ( +
+ +

+ {translate('issues.code_fix.fix_is_being_generated')} +

+
+ )} + {isError && ( +
+ +

+ {translate('issues.code_fix.something_went_wrong')} +

+

{translate('issues.code_fix.not_able_to_generate_fix')}

+ {translate('issues.code_fix.check_how_to_fix')} + {!isIssueRawError && ( + + )} +
+ )} + + {!isPending && !isError && ( + + )} + + ); +} 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) { + const [displayedLine, setDisplayedLine] = useState([]); + + 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 ( +
+ {sourceViewerFile && ( + + )} + + + {displayedLine[0]?.lineBefore !== 1 && ( + + handleExpand( + 0, + max([displayedLine[0].lineBefore - EXPAND_SIZE, 0]), + displayedLine[0].lineBefore, + ) + } + style={{ borderTop: 'none' }} + > + + + )} + {displayedLine.map((line, index) => ( + ${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 ? ( + <> + + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + displayedLine[index - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + > + + + + handleExpand(index, line.lineBefore - EXPAND_SIZE, line.lineBefore) + } + style={{ borderTop: 'none' }} + > + + + + ) : ( + + handleExpand( + index, + displayedLine[index - 1].lineBefore + 1, + line.lineBefore, + ) + } + > + + + )} + + )} +
+ {line.copy !== undefined && ( + + )} + +
+
+ ))} + + {displayedLine[displayedLine.length - 1]?.lineBefore !== + suggestion.unifiedLines[suggestion.unifiedLines.length - 1]?.lineBefore && ( + + handleExpand( + displayedLine.length, + displayedLine[displayedLine.length - 1].lineBefore + 1, + displayedLine[displayedLine.length - 1].lineBefore + EXPAND_SIZE + 1, + ) + } + style={{ borderBottom: 'none' }} + > + + + )} +
+
+

{suggestion.explanation}

+
+ ); +} + +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 ( + + + {type !== LineTypeEnum.ADDED && ( + {lineBefore} + )} + + + {type !== LineTypeEnum.REMOVED && ( + {lineAfter} + )} + + + {type === LineTypeEnum.REMOVED && ( + - + )} + {type === LineTypeEnum.ADDED && +} + + + {type === LineTypeEnum.CODE && ( + + ${line}`} + language={language} + escapeDom={false} + /> + + )} + {type === LineTypeEnum.REMOVED && ( + + + ${line}`} + language={language} + escapeDom={false} + /> + + + )} + {type === LineTypeEnum.ADDED && ( + + + ${line}`} + language={language} + escapeDom={false} + /> + + + )} + + + ); +} + +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 @@ -172,9 +179,12 @@ export class IssueTabViewer extends React.PureComponent { 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 ), }, + ...(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 { - 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 - {tab.content} + + {tab.content} + )) } @@ -402,4 +429,4 @@ export class IssueTabViewer extends React.PureComponent void>(noop); -- cgit v1.2.3