--- /dev/null
+/*
+ * 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 * as React from 'react';
+import { Link } from 'react-router';
+import { getRuleUrl } from '../../../helpers/urls';
+import { Issue, RuleDetails } from '../../../types/types';
+
+interface IssueRuleHeaderProps {
+ ruleDetails: RuleDetails;
+ issue: Issue;
+}
+
+export default function IssueRuleHeader(props: IssueRuleHeaderProps) {
+ const {
+ ruleDetails: { name, key },
+ issue: { message }
+ } = props;
+
+ return (
+ <>
+ <h1 className="text-bold">{message}</h1>
+ <div className="spacer-top big-spacer-bottom">
+ <span className="note padded-right">{name}</span>
+ <Link className="small" to={getRuleUrl(key)} target="_blank">
+ {key}
+ </Link>
+ </div>
+ </>
+ );
+}
--- /dev/null
+/*
+ * 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 classNames from 'classnames';
+import * as React from 'react';
+import BoxedTabs from '../../../components/controls/BoxedTabs';
+import { translate } from '../../../helpers/l10n';
+import { sanitizeString } from '../../../helpers/sanitize';
+import { RuleDescriptionSections, RuleDetails } from '../../../types/types';
+
+interface Props {
+ codeTabContent: React.ReactNode;
+ ruleDetails: RuleDetails;
+}
+
+interface State {
+ currentTabKey: TabKeys;
+ tabs: Tab[];
+}
+
+interface Tab {
+ key: TabKeys;
+ label: React.ReactNode;
+ content: string;
+}
+
+export enum TabKeys {
+ Code = 'code',
+ WhyIsThisAnIssue = 'why',
+ HowToFixIt = 'how',
+ Resources = 'resources'
+}
+
+export default class IssueViewerTabs extends React.PureComponent<Props, State> {
+ constructor(props: Props) {
+ super(props);
+ const tabs = this.computeTabs();
+ this.state = {
+ currentTabKey: tabs[0].key,
+ tabs
+ };
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.ruleDetails !== this.props.ruleDetails) {
+ const tabs = this.computeTabs();
+ this.setState({
+ currentTabKey: tabs[0].key,
+ tabs
+ });
+ }
+ }
+
+ handleSelectTabs = (currentTabKey: TabKeys) => {
+ this.setState({ currentTabKey });
+ };
+
+ computeTabs() {
+ const { ruleDetails } = this.props;
+
+ return [
+ {
+ key: TabKeys.Code,
+ label: translate('issue.tabs.code'),
+ content: ''
+ },
+ {
+ key: TabKeys.WhyIsThisAnIssue,
+ label: translate('issue.tabs.why'),
+ content:
+ ruleDetails.descriptionSections?.find(
+ section => section.key === RuleDescriptionSections.DEFAULT
+ )?.content ?? ''
+ }
+ ];
+ }
+
+ render() {
+ const { codeTabContent } = this.props;
+ const { tabs, currentTabKey } = this.state;
+
+ return (
+ <>
+ <BoxedTabs onSelect={this.handleSelectTabs} selected={currentTabKey} tabs={tabs} />
+ <div className="bordered huge-spacer-bottom">
+ <div
+ className={classNames('padded', {
+ hidden: currentTabKey !== TabKeys.Code
+ })}>
+ {codeTabContent}
+ </div>
+ {tabs.slice(1).map(tab => (
+ <div
+ key={tab.key}
+ className={classNames('markdown big-padded', {
+ hidden: currentTabKey !== tab.key
+ })}
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: sanitizeString(tab.content) }}
+ />
+ ))}
+ </div>
+ </>
+ );
+ }
+}
import { Helmet } from 'react-helmet-async';
import { FormattedMessage } from 'react-intl';
import { searchIssues } from '../../../api/issues';
+import { getRuleDetails } from '../../../api/rules';
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
import EmptySearch from '../../../components/common/EmptySearch';
import FiltersHeader from '../../../components/common/FiltersHeader';
ReferencedRule
} from '../../../types/issues';
import { SecurityStandard } from '../../../types/security';
-import { Component, Dict, Issue, Paging, RawQuery } from '../../../types/types';
+import { Component, Dict, Issue, Paging, RawQuery, RuleDetails } from '../../../types/types';
import { CurrentUser, UserBase } from '../../../types/users';
import * as actions from '../actions';
import ConciseIssuesList from '../conciseIssuesList/ConciseIssuesList';
STANDARDS
} from '../utils';
import BulkChangeModal, { MAX_PAGE_SIZE } from './BulkChangeModal';
+import IssueRuleHeader from './IssueRuleHeader';
import IssuesList from './IssuesList';
import IssuesSourceViewer from './IssuesSourceViewer';
+import IssueTabViewer from './IssueTabViewer';
import MyIssuesFilter from './MyIssuesFilter';
import NoIssues from './NoIssues';
import NoMyIssues from './NoMyIssues';
facets: Dict<Facet>;
issues: Issue[];
loading: boolean;
+ loadingRule: boolean;
loadingFacets: Dict<boolean>;
loadingMore: boolean;
locationsNavigator: boolean;
openFacets: Dict<boolean>;
openIssue?: Issue;
openPopup?: { issue: string; name: string };
+ openRuleDetails?: RuleDetails;
paging?: Paging;
query: Query;
referencedComponentsById: Dict<ReferencedComponent>;
issues: [],
loading: true,
loadingFacets: {},
+ loadingRule: false,
loadingMore: false,
locationsNavigator: false,
myIssues: areMyIssuesSelected(props.location.query),
selectedLocationIndex: undefined
});
}
+ if (this.state.openIssue && this.state.openIssue.key !== prevState.openIssue?.key) {
+ this.loadRule();
+ }
}
componentWillUnmount() {
}
};
+ async loadRule() {
+ const { openIssue } = this.state;
+ if (openIssue === undefined) {
+ return;
+ }
+ this.setState({ loadingRule: true });
+ const openRuleDetails = await getRuleDetails({ key: openIssue.rule })
+ .then(response => response.rule)
+ .catch(() => undefined);
+ if (this.mounted) {
+ this.setState({ loadingRule: false, openRuleDetails });
+ }
+ }
+
selectPreviousIssue = () => {
const { issues } = this.state;
const selectedIndex = this.getSelectedIndex();
}
renderPage() {
- const { cannotShowOpenIssue, checkAll, issues, loading, openIssue, paging } = this.state;
+ const {
+ cannotShowOpenIssue,
+ openRuleDetails,
+ checkAll,
+ issues,
+ loading,
+ openIssue,
+ paging,
+ loadingRule
+ } = this.state;
return (
<div className="layout-page-main-inner">
- {openIssue ? (
- <IssuesSourceViewer
- branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
- issues={issues}
- loadIssues={this.fetchIssuesForComponent}
- locationsNavigator={this.state.locationsNavigator}
- onIssueChange={this.handleIssueChange}
- onIssueSelect={this.openIssue}
- onLocationSelect={this.selectLocation}
- openIssue={openIssue}
- selectedFlowIndex={this.state.selectedFlowIndex}
- selectedLocationIndex={this.state.selectedLocationIndex}
- />
- ) : (
- <DeferredSpinner loading={loading}>
- {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
- <Alert className="big-spacer-bottom" 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> }}
- />
- </Alert>
- )}
- {cannotShowOpenIssue && (!paging || paging.total > 0) && (
- <Alert className="big-spacer-bottom" variant="warning">
- {translateWithParameters(
- 'issues.cannot_open_issue_max_initial_X_fetched',
- MAX_INITAL_FETCH
- )}
- </Alert>
- )}
- {this.renderList()}
- </DeferredSpinner>
- )}
+ <DeferredSpinner loading={loadingRule}>
+ {openIssue && openRuleDetails ? (
+ <>
+ <IssueRuleHeader ruleDetails={openRuleDetails} issue={openIssue} />
+ <IssueTabViewer
+ codeTabContent={
+ <IssuesSourceViewer
+ branchLike={fillBranchLike(openIssue.branch, openIssue.pullRequest)}
+ issues={issues}
+ loadIssues={this.fetchIssuesForComponent}
+ locationsNavigator={this.state.locationsNavigator}
+ onIssueChange={this.handleIssueChange}
+ onIssueSelect={this.openIssue}
+ onLocationSelect={this.selectLocation}
+ openIssue={openIssue}
+ selectedFlowIndex={this.state.selectedFlowIndex}
+ selectedLocationIndex={this.state.selectedLocationIndex}
+ />
+ }
+ ruleDetails={openRuleDetails}
+ />
+ </>
+ ) : (
+ <DeferredSpinner loading={loading}>
+ {checkAll && paging && paging.total > MAX_PAGE_SIZE && (
+ <Alert className="big-spacer-bottom" 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> }}
+ />
+ </Alert>
+ )}
+ {cannotShowOpenIssue && (!paging || paging.total > 0) && (
+ <Alert className="big-spacer-bottom" variant="warning">
+ {translateWithParameters(
+ 'issues.cannot_open_issue_max_initial_X_fetched',
+ MAX_INITAL_FETCH
+ )}
+ </Alert>
+ )}
+ {this.renderList()}
+ </DeferredSpinner>
+ )}
+ </DeferredSpinner>
</div>
);
}
import { shallow } from 'enzyme';
import * as React from 'react';
import { searchIssues } from '../../../../api/issues';
+import { getRuleDetails } from '../../../../api/rules';
import handleRequiredAuthentication from '../../../../helpers/handleRequiredAuthentication';
import { KeyboardCodes, KeyboardKeys } from '../../../../helpers/keycodes';
import { mockPullRequest } from '../../../../helpers/mocks/branch-like';
import BulkChangeModal from '../BulkChangeModal';
import App from '../IssuesApp';
import IssuesSourceViewer from '../IssuesSourceViewer';
+import IssueViewerTabs from '../IssueTabViewer';
jest.mock('../../../../helpers/pages', () => ({
addSideBarClass: jest.fn(),
searchIssues: jest.fn().mockResolvedValue({ facets: [], issues: [] })
}));
+jest.mock('../../../../api/rules', () => ({
+ getRuleDetails: jest.fn()
+}));
+
const RAW_ISSUES = [
mockRawIssue(false, { key: 'foo' }),
mockRawIssue(false, { key: 'bar' }),
rules: [],
users: []
});
+
+ (getRuleDetails as jest.Mock).mockResolvedValue({ rule: { test: 'test' } });
});
afterEach(() => {
- jest.clearAllMocks();
+ // jest.clearAllMocks();
(searchIssues as jest.Mock).mockReset();
});
it('should switch to source view if an issue is selected', async () => {
const wrapper = shallowRender();
await waitAndUpdate(wrapper);
- expect(wrapper.find(IssuesSourceViewer).exists()).toBe(false);
+ expect(wrapper.find(IssueViewerTabs).exists()).toBe(false);
wrapper.setProps({ location: mockLocation({ query: { open: 'third' } }) });
await waitAndUpdate(wrapper);
- expect(wrapper.find(IssuesSourceViewer).exists()).toBe(true);
+ expect(
+ wrapper
+ .find(IssueViewerTabs)
+ .dive()
+ .find(IssuesSourceViewer)
+ .exists()
+ ).toBe(true);
});
it('should correctly bind key events for issue navigation', async () => {
await waitAndUpdate(wrapper);
const issue = wrapper.state().issues[0];
+
wrapper.setProps({ location: mockLocation({ query: { open: issue.key } }) });
await waitAndUpdate(wrapper);
hidden: currentTab.key !== tab.key
})}
// eslint-disable-next-line react/no-danger
- dangerouslySetInnerHTML={{ __html: sanitizeString(currentTab.content) }}
+ dangerouslySetInnerHTML={{ __html: sanitizeString(tab.content) }}
/>
))}
</div>
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+ "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
}
}
key="risk"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
+ "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
}
}
key="vulnerability"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
}
}
key="risk"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
}
}
key="vulnerability"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
}
}
key="fix"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+ "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
}
}
key="risk"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
+ "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
}
}
key="fix"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about risk !</p>",
}
}
key="risk"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about vulnerability !</p>",
}
}
key="vulnerability"
className="markdown big-padded hidden"
dangerouslySetInnerHTML={
Object {
- "__html": "",
+ "__html": "<p>This a <strong>strong</strong> message about fixing !</p>",
}
}
key="fix"
HOW_TO_FIX = 'how_to_fix',
RESOURCES = 'resources'
}
-
export interface RuleDescriptionSection {
key: RuleDescriptionSections;
content: string;
defaultDebtRemFnType?: string;
defaultRemFnBaseEffort?: string;
defaultRemFnType?: string;
- effortToFixDescription?: string;
descriptionSections?: RuleDescriptionSection[];
+ effortToFixDescription?: string;
htmlDesc?: string;
htmlNote?: string;
internalKey?: string;
issue.transition.resolveasreviewed.description=There is no Vulnerability in the code
issue.transition.resetastoreview=Reset as To Review
issue.transition.resetastoreview.description=The Security Hotspot should be analyzed again
+issue.tabs.code=Where is the issue?
+issue.tabs.why=Why is this an issue?
+issue.tabs.how=How to fix it?
+issue.tabs.resources=Resources
+
vulnerability.transition.resetastoreview=Reset as To Review
vulnerability.transition.resetastoreview.description=The vulnerability can't be fixed as is and needs more details. The security hotspot needs to be reviewed again
vulnerability.transition.resolveasreviewed=Resolve as Reviewed