aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/components
diff options
context:
space:
mode:
authorRevanshu Paliwal <revanshu.paliwal@sonarsource.com>2024-08-28 16:40:16 +0200
committersonartech <sonartech@sonarsource.com>2024-09-04 20:03:11 +0000
commit0996c6186cfeb8e2c763d2f174b26ab276b232f7 (patch)
tree6a56285befb26ce1b41d9065ab86379cb91d31ac /server/sonar-web/src/main/js/components
parentbed8af05f0c3f2b4c6018634d3d7aaba5b45a83c (diff)
downloadsonarqube-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')
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionCodeTab.tsx91
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionFileSnippet.tsx214
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueSuggestionLine.tsx145
-rw-r--r--server/sonar-web/src/main/js/components/rules/IssueTabViewer.tsx43
-rw-r--r--server/sonar-web/src/main/js/components/rules/TabSelectorContext.ts24
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);