& mark {
background-color: ${themeColor('searchHighlight')};
+ font-weight: 400;
}
}
'/dashboard',
'/security_hotspots',
'/component_measures',
+ '/project/issues',
];
export default function GlobalContainer() {
expect(
await screen.findByRole('tab', {
name: `coding_rules.description_section.title.root_cause`,
- selected: true,
})
- ).toBeInTheDocument();
+ ).toHaveAttribute('aria-current', 'true');
});
it('should interact with flows and locations', async () => {
expect(
screen.queryByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
- selected: true,
})
- ).not.toBeInTheDocument();
+ ).toHaveAttribute('aria-current', 'false');
await user.click(screen.getByRole('link', { name: 'location 1' }));
expect(
- screen.getByRole('tab', {
+ screen.queryByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
- selected: true,
})
- ).toBeInTheDocument();
+ ).toHaveAttribute('aria-current', 'true');
// Select the same selected hotspot location should also navigate back to code page
await user.click(
expect(
screen.queryByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
- selected: true,
})
- ).not.toBeInTheDocument();
+ ).toHaveAttribute('aria-current', 'false');
await user.click(screen.getByRole('link', { name: 'location 1' }));
expect(
- screen.getByRole('tab', {
+ screen.queryByRole('tab', {
name: `issue.tabs.${TabKeys.Code}`,
- selected: true,
})
- ).toBeInTheDocument();
+ ).toHaveAttribute('aria-current', 'true');
});
it('should show issue tags if applicable', async () => {
'issues.on_file_x',
`${displayProject ? issue.projectName + ', ' : ''}${componentName}`
)}
- className="sw-flex sw-box-border sw-body-sm sw-w-full sw-pb-1 sw-pt-6 sw-truncate"
+ className="sw-flex sw-box-border sw-body-sm sw-w-full sw-pb-2 sw-pt-4 sw-truncate"
>
{displayProject && (
<span title={projectName}>
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import classNames from 'classnames';
-import { ButtonSecondary, Checkbox, FlagMessage, ToggleButton } from 'design-system';
+import styled from '@emotion/styled';
+import {
+ ButtonSecondary,
+ Checkbox,
+ FlagMessage,
+ LargeCenteredLayout,
+ PageContentFontWrapper,
+ ToggleButton,
+ themeBorder,
+ themeColor,
+} from 'design-system';
import { debounce, keyBy, omit, without } from 'lodash';
import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
import withIndexationGuard from '../../../components/hoc/withIndexationGuard';
import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
-import RuleTabViewer from '../../../components/rules/RuleTabViewer';
+import IssueTabViewer from '../../../components/rules/IssueTabViewer';
import '../../../components/search-navigator.css';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import {
<div
className={
'it__layout-page-filters sw-bg-white sw-box-border sw-h-full sw-overflow-y-auto ' +
- 'sw-pt-6 sw-pl-3 sw-pr-4 sw-w-[300px] lg:sw-w-[390px]'
+ 'sw-py-6 sw-pl-3 sw-pr-4 sw-w-[300px] lg:sw-w-[390px]'
}
- style={{ borderLeft: '1px solid #dddddd', borderTop: '1px solid #dddddd' }}
>
- {warning}
+ <div className="sw-pb-6">{warning}</div>
{currentUser.isLoggedIn && (
<div className="sw-flex sw-justify-start sw-mb-8">
const warning = !canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
<FlagMessage
ariaLabel={translate('issues.not_all_issue_show')}
- className="it__portfolio_warning sw-flex sw-my-4"
+ className="it__portfolio_warning sw-flex"
title={translate('issues.not_all_issue_show_why')}
variant="warning"
>
);
return (
- <ScreenPositionHelper className="layout-page-side-outer">
+ <ScreenPositionHelper className="sw-z-filterbar">
{({ top }) => (
<nav
aria-label={openIssue ? translate('list_of_issues') : translate('filters')}
- className="layout-page-side"
- style={{ top }}
+ className="it__issues-nav-bar sw-overflow-scroll"
+ style={{ height: `calc((100vh - ${top}px) - 60px)` }} // 60px (footer)
>
- <div className="sw-flex sw-h-full sw-justify-end">
+ <SideBarStyle className="sw-w-[300px] lg:sw-w-[390px]">
<A11ySkipTarget
anchor="issues_sidebar"
label={
{openIssue ? (
<div>
- <div className={classNames('not-all-issue-warning', 'open-issue-list')}>
- {warning}
- </div>
+ {warning && <div className="sw-py-4">{warning}</div>}
<SubnavigationIssuesList
fetchMoreIssues={this.fetchMoreIssues}
) : (
this.renderFacets(warning)
)}
- </div>
+ </SideBarStyle>
</nav>
)}
</ScreenPositionHelper>
return openIssue ? (
<A11ySkipTarget anchor="issues_main" />
) : (
- <div className="layout-page-header-panel layout-page-main-header issues-main-header">
- <div className="layout-page-header-panel-inner layout-page-main-header-inner">
- <div className="layout-page-main-inner">
- <A11ySkipTarget anchor="issues_main" />
-
+ <>
+ <A11ySkipTarget anchor="issues_main" />
+ <div className="sw-flex sw-mb-6 sw-gap-4">
+ <div className="sw-flex sw-w-full sw-items-center sw-justify-between">
{this.renderBulkChange()}
<PageActions
/>
</div>
</div>
- </div>
+ </>
);
}
loadingRule,
} = this.state;
return (
- <div className="layout-page-main-inner">
- <DeferredSpinner loading={loadingRule}>
- {/* eslint-disable-next-line local-rules/no-conditional-rendering-of-deferredspinner */}
- {openIssue && openRuleDetails ? (
- <>
- <IssueHeader
- issue={openIssue}
- ruleDetails={openRuleDetails}
- branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
- onIssueChange={this.handleIssueChange}
- />
-
- <RuleTabViewer
- ruleDetails={openRuleDetails}
- extendedDescription={openRuleDetails.htmlNote}
- ruleDescriptionContextKey={openIssue.ruleDescriptionContextKey}
- issue={openIssue}
- selectedFlowIndex={this.state.selectedFlowIndex}
- selectedLocationIndex={this.state.selectedLocationIndex}
- codeTabContent={
- <IssuesSourceViewer
+ <ScreenPositionHelper>
+ {({ top }) => (
+ <div
+ className="it__layout-page-main-inner sw-overflow-scroll"
+ style={{ height: `calc((100vh - ${top}px) - 120px)` }} // 120px = 60px (footer) + 60px (bulk change bar)
+ >
+ <DeferredSpinner loading={loadingRule}>
+ {/* eslint-disable-next-line local-rules/no-conditional-rendering-of-deferredspinner */}
+ {openIssue && openRuleDetails ? (
+ <>
+ <IssueHeader
+ issue={openIssue}
+ ruleDetails={openRuleDetails}
branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
- issues={issues}
- locationsNavigator={this.state.locationsNavigator}
- onIssueSelect={this.openIssue}
- onLocationSelect={this.selectLocation}
- openIssue={openIssue}
- selectedFlowIndex={this.state.selectedFlowIndex}
- selectedLocationIndex={this.state.selectedLocationIndex}
+ onIssueChange={this.handleIssueChange}
/>
- }
- scrollInTab
- activityTabContent={
- <IssueReviewHistoryAndComments
+
+ <IssueTabViewer
+ ruleDetails={openRuleDetails}
+ extendedDescription={openRuleDetails.htmlNote}
+ ruleDescriptionContextKey={openIssue.ruleDescriptionContextKey}
issue={openIssue}
- onChange={this.handleIssueChange}
- />
- }
- />
- </>
- ) : (
- <DeferredSpinner loading={loading} ariaLabel={translate('issues.loading_issues')}>
- {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
- <FlagMessage
- ariaLabel={translate('issue_bulk_change.max_issues_reached')}
- className="sw-mb-4"
- variant="warning"
- >
- <FormattedMessage
- defaultMessage={translate('issue_bulk_change.max_issues_reached')}
- id="issue_bulk_change.max_issues_reached"
- values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
+ selectedFlowIndex={this.state.selectedFlowIndex}
+ selectedLocationIndex={this.state.selectedLocationIndex}
+ codeTabContent={
+ <IssuesSourceViewer
+ branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+ issues={issues}
+ locationsNavigator={this.state.locationsNavigator}
+ onIssueSelect={this.openIssue}
+ onLocationSelect={this.selectLocation}
+ openIssue={openIssue}
+ selectedFlowIndex={this.state.selectedFlowIndex}
+ selectedLocationIndex={this.state.selectedLocationIndex}
+ />
+ }
+ scrollInTab
+ activityTabContent={
+ <IssueReviewHistoryAndComments
+ issue={openIssue}
+ onChange={this.handleIssueChange}
+ />
+ }
/>
- </FlagMessage>
- )}
-
- {cannotShowOpenIssue && (!paging || paging.total > 0) && (
- <FlagMessage
- ariaLabel={translateWithParameters(
- 'issues.cannot_open_issue_max_initial_X_fetched',
- MAX_INITAL_FETCH
+ </>
+ ) : (
+ <DeferredSpinner loading={loading} ariaLabel={translate('issues.loading_issues')}>
+ {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
+ <FlagMessage
+ ariaLabel={translate('issue_bulk_change.max_issues_reached')}
+ variant="warning"
+ >
+ <span>
+ <FormattedMessage
+ defaultMessage={translate('issue_bulk_change.max_issues_reached')}
+ id="issue_bulk_change.max_issues_reached"
+ values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
+ />
+ </span>
+ </FlagMessage>
)}
- className="sw-mb-4"
- variant="warning"
- >
- {translateWithParameters(
- 'issues.cannot_open_issue_max_initial_X_fetched',
- MAX_INITAL_FETCH
+
+ {cannotShowOpenIssue && (!paging || paging.total > 0) && (
+ <FlagMessage
+ ariaLabel={translateWithParameters(
+ 'issues.cannot_open_issue_max_initial_X_fetched',
+ MAX_INITAL_FETCH
+ )}
+ className="sw-mb-4"
+ variant="warning"
+ >
+ {translateWithParameters(
+ 'issues.cannot_open_issue_max_initial_X_fetched',
+ MAX_INITAL_FETCH
+ )}
+ </FlagMessage>
)}
- </FlagMessage>
- )}
- {this.renderList()}
+ {this.renderList()}
+ </DeferredSpinner>
+ )}
</DeferredSpinner>
- )}
- </DeferredSpinner>
- </div>
+ </div>
+ )}
+ </ScreenPositionHelper>
);
}
render() {
- const { component } = this.props;
const { openIssue, paging } = this.state;
const selectedIndex = this.getSelectedIndex();
return (
- <div
- className={classNames('layout-page issues', { 'project-level': component !== undefined })}
- id="issues-page"
- >
- <Suggestions suggestions="issues" />
-
- {openIssue ? (
- <Helmet
- defer={false}
- title={openIssue.message}
- titleTemplate={translateWithParameters(
- 'page_title.template.with_category',
- translate('issues.page')
- )}
- />
- ) : (
- <Helmet defer={false} title={translate('issues.page')} />
- )}
+ <PageWrapperStyle id="issues-page">
+ <LargeCenteredLayout>
+ <PageContentFontWrapper className="sw-body-sm">
+ <div className="sw-w-full sw-flex" id="issues-page">
+ <Suggestions suggestions="issues" />
- <h1 className="a11y-hidden">{translate('issues.page')}</h1>
+ {openIssue ? (
+ <Helmet
+ defer={false}
+ title={openIssue.message}
+ titleTemplate={translateWithParameters(
+ 'page_title.template.with_category',
+ translate('issues.page')
+ )}
+ />
+ ) : (
+ <Helmet defer={false} title={translate('issues.page')} />
+ )}
- {this.renderSide(openIssue)}
+ <h1 className="a11y-hidden">{translate('issues.page')}</h1>
- <div role="main" className={classNames('layout-page-main', { 'open-issue': !!openIssue })}>
- {this.renderHeader({ openIssue, paging, selectedIndex })}
+ {this.renderSide(openIssue)}
- {this.renderPage()}
- </div>
- </div>
+ <MainContentStyle role="main" className="sw-relative sw-ml-2 sw-p-6 sw-flex-1">
+ {this.renderHeader({ openIssue, paging, selectedIndex })}
+
+ {this.renderPage()}
+ </MainContentStyle>
+ </div>
+ </PageContentFontWrapper>
+ </LargeCenteredLayout>
+ </PageWrapperStyle>
);
}
}
withRouter(withCurrentUserContext(withBranchStatusActions(withComponentContext(App)))),
PageContext.Issues
);
+
+const PageWrapperStyle = styled.div`
+ background-color: ${themeColor('backgroundPrimary')};
+`;
+
+const MainContentStyle = styled.div`
+ background-color: ${themeColor('subnavigation')};
+ border-left: ${themeBorder('default', 'filterbarBorder')};
+ border-right: ${themeBorder('default', 'filterbarBorder')};
+`;
+
+const SideBarStyle = styled.div`
+ border-left: ${themeBorder('default', 'filterbarBorder')};
+ border-right: ${themeBorder('default', 'filterbarBorder')};
+ background-color: ${themeColor('backgroundSecondary')};
+`;
const { canSetHome, effortTotal, paging, selectedIndex } = props;
return (
- <div className="sw-body-sm sw-flex sw-gap-6 sw-justify-end">
+ <div className="sw-body-sm sw-flex sw-items-center sw-gap-6 sw-justify-end">
<KeyboardHint title={translate('issues.to_select_issues')} command="ArrowUp ArrowDown" />
<KeyboardHint title={translate('issues.to_navigate')} command="ArrowLeft ArrowRight" />
*/
import styled from '@emotion/styled';
import classNames from 'classnames';
-import { FlagMessage, LineFinding, themeColor } from 'design-system';
+import { FlagMessage, IssueMessageHighlighting, LineFinding, themeColor } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import { getSources } from '../../../api/components';
import getCoverageStatus from '../../../components/SourceViewer/helpers/getCoverageStatus';
import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing';
-import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
import { getBranchLikeQuery } from '../../../helpers/branch-like';
import { translate } from '../../../helpers/l10n';
import { BranchLike } from '../../../types/branch-like';
text={locationType.toUpperCase()}
/>
)}
- <span>{normalizedMessage ?? translate('issue.unnamed_location')}</span>
+ <StyledLocationName>
+ {normalizedMessage ?? translate('issue.unnamed_location')}
+ </StyledLocationName>
</span>
</StyledLocation>
</StyledLink>
border: none;
`;
+const StyledLocationName = styled.span`
+ word-break: break-word;
+`;
+
function getLocationType(message?: string) {
if (message?.toLowerCase().startsWith('source')) {
return 'source';
React.useEffect(() => {
if (selected && element.current) {
- const parent = document.querySelector('.layout-page-side') as HTMLMenuElement;
+ const parent = document.querySelector('nav.it__issues-nav-bar') as HTMLMenuElement;
const rect = parent.getBoundingClientRect();
const offset =
element.current.offsetTop - rect.height / HALF_DIVIDER + rect.top / HALF_DIVIDER;
`;
const StyledIssueTitle = styled(BareButton)`
+ word-break: break-word;
&:focus {
background-color: ${themeColor('subnavigationSelected')};
}
const { searching, searchMaxResults, searchResults, searchPaging } = this.state;
if (!searching && !searchResults?.length) {
- return <div className="note spacer-bottom">{translate('no_results')}</div>;
+ return <div className="sw-mb-2">{translate('no_results')}</div>;
}
if (!searchResults) {
*/
import { useTheme } from '@emotion/react';
-import { BaseLink, Theme, themeColor } from 'design-system';
+import { DiscreetLink, Theme, themeColor } from 'design-system';
import * as React from 'react';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { formatMeasure } from '../../../helpers/measures';
return (
<div
- className="sw-body-xs sw-mb-2 sw-mt-2 sw-text-center"
+ className="sw-mb-2 sw-mt-2 sw-text-center"
style={{ color: themeColor('graphCursorLineColor')({ theme }) }}
>
{translateWithParameters('x_show', formatMeasure(nbShown, MetricType.Integer))}
{hasMore && (
- <BaseLink
+ <DiscreetLink
aria-label={showMoreAriaLabel}
className="sw-ml-2"
onClick={(e) => {
to="#"
>
{translate('show_more')}
- </BaseLink>
+ </DiscreetLink>
)}
{showLess && allShown && (
- <BaseLink
+ <DiscreetLink
aria-label={showLessAriaLabel}
className="sw-ml-2"
onClick={(e) => {
to="#"
>
{translate('show_less')}
- </BaseLink>
+ </DiscreetLink>
)}
</div>
);
value="blabla"
/>
<div
- className="note spacer-bottom"
+ className="sw-mb-2"
>
no_results
</div>
value="blabla"
/>
<div
- className="note spacer-bottom"
+ className="sw-mb-2"
>
no_results
</div>
&.selected {
border: ${themeBorder('default', 'tableRowSelected')};
- &:last-child {
- }
}
&:hover {
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 classNames from 'classnames';
+import { ToggleButton } from 'design-system';
+import { cloneDeep, debounce, groupBy } from 'lodash';
+import * as React from 'react';
+import { Location } from 'react-router-dom';
+import { dismissNotice } from '../../api/users';
+import { CurrentUserContextInterface } from '../../app/components/current-user/CurrentUserContext';
+import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext';
+import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
+import { translate } from '../../helpers/l10n';
+import { Issue, RuleDetails } from '../../types/types';
+import { NoticeType } from '../../types/users';
+import ScreenPositionHelper from '../common/ScreenPositionHelper';
+import withLocation from '../hoc/withLocation';
+import MoreInfoRuleDescription from './MoreInfoRuleDescription';
+import RuleDescription from './RuleDescription';
+
+import './style.css';
+
+interface IssueTabViewerProps extends CurrentUserContextInterface {
+ ruleDetails: RuleDetails;
+ extendedDescription?: string;
+ ruleDescriptionContextKey?: string;
+ codeTabContent?: React.ReactNode;
+ activityTabContent?: React.ReactNode;
+ scrollInTab?: boolean;
+ location: Location;
+ selectedFlowIndex?: number;
+ selectedLocationIndex?: number;
+ issue?: Issue;
+}
+
+interface State {
+ tabs: Tab[];
+ selectedTab?: Tab;
+ displayEducationalPrinciplesNotification?: boolean;
+ educationalPrinciplesNotificationHasBeenDismissed?: boolean;
+}
+
+export interface Tab {
+ value: TabKeys;
+ key: TabKeys;
+ label: string;
+ content: React.ReactNode;
+ counter?: number;
+}
+
+export enum TabKeys {
+ Code = 'code',
+ WhyIsThisAnIssue = 'why',
+ HowToFixIt = 'how_to_fix',
+ AssessTheIssue = 'assess_the_problem',
+ Activity = 'activity',
+ MoreInfo = 'more_info',
+}
+
+const DEBOUNCE_FOR_SCROLL = 250;
+
+export class IssueTabViewer extends React.PureComponent<IssueTabViewerProps, State> {
+ state: State = {
+ tabs: [],
+ };
+
+ educationPrinciplesRef: React.RefObject<HTMLDivElement>;
+
+ constructor(props: IssueTabViewerProps) {
+ super(props);
+ this.educationPrinciplesRef = React.createRef();
+ this.checkIfEducationPrinciplesAreVisible = debounce(
+ this.checkIfEducationPrinciplesAreVisible,
+ DEBOUNCE_FOR_SCROLL
+ );
+ }
+
+ componentDidMount() {
+ this.setState((prevState) => this.computeState(prevState));
+ this.attachScrollEvent();
+
+ const tabs = this.computeTabs(Boolean(this.state.displayEducationalPrinciplesNotification));
+
+ const query = new URLSearchParams(this.props.location.search);
+ if (query.has('why')) {
+ this.setState({
+ selectedTab: tabs.find((tab) => tab.key === TabKeys.WhyIsThisAnIssue) || tabs[0],
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps: IssueTabViewerProps, prevState: State) {
+ const {
+ ruleDetails,
+ ruleDescriptionContextKey,
+ currentUser,
+ issue,
+ selectedFlowIndex,
+ selectedLocationIndex,
+ } = this.props;
+ const { selectedTab } = this.state;
+
+ if (
+ prevProps.ruleDetails.key !== ruleDetails.key ||
+ prevProps.ruleDescriptionContextKey !== ruleDescriptionContextKey ||
+ prevProps.issue !== issue ||
+ prevProps.selectedFlowIndex !== selectedFlowIndex ||
+ prevProps.selectedLocationIndex !== selectedLocationIndex ||
+ prevProps.currentUser !== currentUser
+ ) {
+ this.setState((pState) =>
+ this.computeState(
+ pState,
+ prevProps.ruleDetails !== ruleDetails ||
+ (prevProps.issue && issue && prevProps.issue.key !== issue.key) ||
+ prevProps.selectedFlowIndex !== selectedFlowIndex ||
+ prevProps.selectedLocationIndex !== selectedLocationIndex
+ )
+ );
+ }
+
+ if (selectedTab?.key === TabKeys.MoreInfo) {
+ this.checkIfEducationPrinciplesAreVisible();
+ }
+
+ if (
+ prevState.selectedTab?.key === TabKeys.MoreInfo &&
+ prevState.displayEducationalPrinciplesNotification &&
+ prevState.educationalPrinciplesNotificationHasBeenDismissed
+ ) {
+ this.props.updateDismissedNotices(NoticeType.EDUCATION_PRINCIPLES, true);
+ }
+ }
+
+ componentWillUnmount() {
+ this.detachScrollEvent();
+ }
+
+ computeState = (prevState: State, resetSelectedTab = false) => {
+ const {
+ ruleDetails,
+ currentUser: { isLoggedIn, dismissedNotices },
+ } = this.props;
+
+ const displayEducationalPrinciplesNotification =
+ !!ruleDetails.educationPrinciples &&
+ ruleDetails.educationPrinciples.length > 0 &&
+ isLoggedIn &&
+ !dismissedNotices[NoticeType.EDUCATION_PRINCIPLES];
+ const tabs = this.computeTabs(displayEducationalPrinciplesNotification);
+
+ return {
+ tabs,
+ selectedTab: resetSelectedTab || !prevState.selectedTab ? tabs[0] : prevState.selectedTab,
+ displayEducationalPrinciplesNotification,
+ };
+ };
+
+ computeTabs = (displayEducationalPrinciplesNotification: boolean) => {
+ const {
+ codeTabContent,
+ ruleDetails: { descriptionSections, educationPrinciples, type: ruleType },
+ ruleDescriptionContextKey,
+ extendedDescription,
+ activityTabContent,
+ issue,
+ } = this.props;
+
+ // As we might tamper with the description later on, we clone to avoid any side effect
+ const descriptionSectionsByKey = cloneDeep(
+ groupBy(descriptionSections, (section) => section.key)
+ );
+
+ if (extendedDescription) {
+ if (descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]?.length > 0) {
+ // We add the extended description (htmlNote) in the first context, in case there are contexts
+ // Extended description will get reworked in future
+ descriptionSectionsByKey[RuleDescriptionSections.RESOURCES][0].content +=
+ '<br/>' + extendedDescription;
+ } else {
+ descriptionSectionsByKey[RuleDescriptionSections.RESOURCES] = [
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: extendedDescription,
+ },
+ ];
+ }
+ }
+
+ const tabs: Tab[] = [
+ {
+ value: TabKeys.WhyIsThisAnIssue,
+ key: TabKeys.WhyIsThisAnIssue,
+ label:
+ ruleType === 'SECURITY_HOTSPOT'
+ ? translate('coding_rules.description_section.title.root_cause.SECURITY_HOTSPOT')
+ : translate('coding_rules.description_section.title.root_cause'),
+ content: (descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
+ descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]) && (
+ <RuleDescription
+ sections={
+ descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
+ descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]
+ }
+ defaultContextKey={ruleDescriptionContextKey}
+ />
+ ),
+ },
+ {
+ value: TabKeys.AssessTheIssue,
+ key: TabKeys.AssessTheIssue,
+ label: translate('coding_rules.description_section.title', TabKeys.AssessTheIssue),
+ content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
+ <RuleDescription
+ sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
+ />
+ ),
+ },
+ {
+ value: TabKeys.HowToFixIt,
+ key: TabKeys.HowToFixIt,
+ label: translate('coding_rules.description_section.title', TabKeys.HowToFixIt),
+ content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
+ <RuleDescription
+ sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
+ defaultContextKey={ruleDescriptionContextKey}
+ />
+ ),
+ },
+ {
+ value: TabKeys.Activity,
+ key: TabKeys.Activity,
+ label: translate('coding_rules.description_section.title', TabKeys.Activity),
+ content: activityTabContent,
+ counter: issue?.comments?.length,
+ },
+ {
+ value: TabKeys.MoreInfo,
+ key: TabKeys.MoreInfo,
+ label: translate('coding_rules.description_section.title', TabKeys.MoreInfo),
+ content: ((educationPrinciples && educationPrinciples.length > 0) ||
+ descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]) && (
+ <MoreInfoRuleDescription
+ educationPrinciples={educationPrinciples}
+ sections={descriptionSectionsByKey[RuleDescriptionSections.RESOURCES]}
+ displayEducationalPrinciplesNotification={displayEducationalPrinciplesNotification}
+ educationPrinciplesRef={this.educationPrinciplesRef}
+ />
+ ),
+ counter: displayEducationalPrinciplesNotification ? 1 : undefined,
+ },
+ ];
+
+ if (codeTabContent !== undefined) {
+ tabs.unshift({
+ value: TabKeys.Code,
+ key: TabKeys.Code,
+ label: translate('issue.tabs', TabKeys.Code),
+ content: codeTabContent,
+ });
+ }
+
+ return tabs.filter((tab) => tab.content);
+ };
+
+ attachScrollEvent = () => {
+ document.addEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, {
+ capture: true,
+ });
+ };
+
+ detachScrollEvent = () => {
+ document.removeEventListener('scroll', this.checkIfEducationPrinciplesAreVisible, {
+ capture: true,
+ });
+ };
+
+ checkIfEducationPrinciplesAreVisible = () => {
+ const {
+ displayEducationalPrinciplesNotification,
+ educationalPrinciplesNotificationHasBeenDismissed,
+ } = this.state;
+
+ if (this.educationPrinciplesRef.current) {
+ const rect = this.educationPrinciplesRef.current.getBoundingClientRect();
+ const isVisible = rect.top <= (window.innerHeight || document.documentElement.clientHeight);
+
+ if (
+ isVisible &&
+ displayEducationalPrinciplesNotification &&
+ !educationalPrinciplesNotificationHasBeenDismissed
+ ) {
+ dismissNotice(NoticeType.EDUCATION_PRINCIPLES)
+ .then(() => {
+ this.detachScrollEvent();
+ this.setState({ educationalPrinciplesNotificationHasBeenDismissed: true });
+ })
+ .catch(() => {
+ /* noop */
+ });
+ }
+ }
+ };
+
+ handleSelectTabs = (currentTabKey: TabKeys) => {
+ this.setState(({ tabs }) => ({
+ selectedTab: tabs.find((tab) => tab.key === currentTabKey) || tabs[0],
+ }));
+ };
+
+ render() {
+ const { scrollInTab } = this.props;
+ const { tabs, selectedTab } = this.state;
+
+ if (!tabs || tabs.length === 0 || !selectedTab) {
+ return null;
+ }
+
+ return (
+ <>
+ <div className="sw-mb-4">
+ <ToggleButton
+ role="tablist"
+ value={selectedTab.key}
+ options={tabs}
+ onChange={this.handleSelectTabs}
+ />
+ </div>
+ <ScreenPositionHelper>
+ {({ top }) => (
+ <div
+ style={{
+ // We substract the footer height with padding (80) and the main layout padding (20) and the tabs padding (20)
+ maxHeight: scrollInTab ? `calc(100vh - ${top + 120}px)` : 'initial',
+ }}
+ className="sw-flex sw-flex-col"
+ role="tabpanel"
+ aria-labelledby={`tab-${selectedTab.key}`}
+ id={`tabpanel-${selectedTab.key}`}
+ >
+ {
+ // Preserve tabs state by always rendering all of them. Only hide them when not selected
+ tabs.map((tab) => (
+ <div
+ className={classNames('sw-overflow-y-auto', {
+ hidden: tab.key !== selectedTab.key,
+ })}
+ key={tab.key}
+ >
+ {tab.content}
+ </div>
+ ))
+ }
+ </div>
+ )}
+ </ScreenPositionHelper>
+ </>
+ );
+ }
+}
+
+export default withCurrentUserContext(withLocation(IssueTabViewer));