--- /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 styled from '@emotion/styled';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../helpers';
+
+export const HtmlFormatter = styled.div`
+ ${tw`sw-my-6`}
+ ${tw`sw-body-sm`}
+
+ a {
+ color: ${themeColor('linkDefault')};
+ border-bottom: ${themeBorder('default', 'linkDefault')};
+ ${tw`sw-no-underline sw-body-sm-highlight`};
+
+ &:visited {
+ color: ${themeColor('linkDefault')};
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ color: ${themeColor('linkActive')};
+ border-bottom: ${themeBorder('default', 'linkDefault')};
+ }
+ }
+
+ p,
+ ul,
+ ol,
+ pre,
+ blockquote,
+ table {
+ color: ${themeColor('pageContent')};
+ ${tw`sw-mb-4`}
+ }
+
+ h2,
+ h3 {
+ color: ${themeColor('pageTitle')};
+ ${tw`sw-heading-md`}
+ ${tw`sw-my-6`}
+ }
+
+ h4,
+ h5,
+ h6 {
+ color: ${themeColor('pageContent')};
+ ${tw`sw-body-md-highlight`}
+ ${tw`sw-mt-6 sw-mb-2`}
+ }
+
+ pre,
+ code {
+ background-color: ${themeColor('codeSnippetBackground')};
+ border: ${themeBorder('default', 'codeSnippetBorder')};
+ ${tw`sw-code`}
+ }
+
+ pre {
+ ${tw`sw-rounded-2`}
+ ${tw`sw-relative`}
+ ${tw`sw-my-2`}
+
+ ${tw`sw-overflow-x-auto`}
+ ${tw`sw-p-6`}
+ }
+
+ code {
+ ${tw`sw-m-0`}
+ /* 1px override is needed to prevent overlap of other code "tags" */
+ ${tw`sw-py-[1px] sw-px-1`}
+ ${tw`sw-rounded-1`}
+ ${tw`sw-whitespace-nowrap`}
+ }
+
+ pre > code {
+ ${tw`sw-p-0`}
+ ${tw`sw-whitespace-pre`}
+ background-color: transparent;
+ }
+
+ blockquote {
+ ${tw`sw-px-4`}
+ line-height: 1.5;
+ }
+
+ ul {
+ ${tw`sw-pl-6`}
+ ${tw`sw-flex sw-flex-col sw-gap-2`}
+ list-style-type: disc;
+
+ li::marker {
+ color: ${themeColor('listMarker')};
+ }
+ }
+
+ li > ul {
+ ${tw`sw-my-2 sw-mx-0`}
+ }
+
+ ol {
+ ${tw`sw-pl-10`};
+ list-style-type: decimal;
+ }
+
+ table {
+ ${tw`sw-min-w-[50%]`}
+ border: ${themeBorder('default')};
+ border-collapse: collapse;
+ }
+
+ th {
+ ${tw`sw-py-1 sw-px-3`}
+ ${tw`sw-body-sm-highlight`}
+ ${tw`sw-text-center`}
+ background-color: ${themeColor('backgroundPrimary')};
+ border: ${themeBorder('default')};
+ }
+
+ td {
+ ${tw`sw-py-1 sw-px-3`}
+ border: ${themeBorder('default')};
+ }
+`;
import styled from '@emotion/styled';
import tw from 'twin.macro';
+import { getTabId, getTabPanelId } from '../helpers/tabs';
import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
import { Badge } from './Badge';
import { ButtonSecondary } from './buttons';
label?: string;
onChange: (value: T) => void;
options: Array<ToggleButtonsOption<T>>;
+ role?: 'radiogroup' | 'tablist';
value?: T;
}
export function ToggleButton<T extends ToggleButtonValueType>(props: ButtonToggleProps<T>) {
- const { disabled = false, label, options, value } = props;
+ const { disabled = false, label, options, value, role = 'radiogroup' } = props;
+ const isRadioGroup = role === 'radiogroup';
return (
- <Wrapper aria-label={label} role="radiogroup">
+ <Wrapper aria-label={label} role={role}>
{options.map((option) => (
<OptionButton
+ aria-controls={isRadioGroup ? undefined : getTabPanelId(String(option.value))}
aria-current={option.value === value}
data-value={option.value}
disabled={disabled || option.disabled}
+ id={getTabId(String(option.value))}
key={option.value.toString()}
onClick={() => {
if (option.value !== value) {
props.onChange(option.value);
}
}}
- role="radio"
+ role={isRadioGroup ? 'radio' : 'tab'}
selected={option.value === value}
>
{option.label}
*/
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { getTabPanelId } from '../../helpers';
import { render } from '../../helpers/testUtils';
import { FCProps } from '../../types/misc';
import { ToggleButton, ToggleButtonsOption } from '../ToggleButton';
it('should render all options', async () => {
const user = userEvent.setup();
-
const onChange = jest.fn();
-
const options: Array<ToggleButtonsOption<number>> = [
{ value: 1, label: 'first' },
{ value: 2, label: 'disabled', disabled: true },
{ value: 3, label: 'has counter', counter: 7 },
];
-
renderToggleButtons({ onChange, options, value: 1 });
expect(screen.getAllByRole('radio')).toHaveLength(3);
expect(onChange).toHaveBeenCalledWith(3);
});
+it('should work in tablist mode', () => {
+ const onChange = jest.fn();
+ const options: Array<ToggleButtonsOption<number>> = [
+ { value: 1, label: 'first' },
+ { value: 2, label: 'second' },
+ { value: 3, label: 'third' },
+ ];
+ renderToggleButtons({ onChange, options, value: 1, role: 'tablist' });
+
+ expect(screen.getAllByRole('tab')).toHaveLength(3);
+ expect(screen.getByRole('tab', { name: 'second' })).toHaveAttribute(
+ 'aria-controls',
+ getTabPanelId(2)
+ );
+});
+
function renderToggleButtons(props: Partial<FCProps<typeof ToggleButton>> = {}) {
return render(<ToggleButton onChange={jest.fn()} options={[]} {...props} />);
}
export * from './GenericAvatar';
export * from './HighlightedSection';
export { HotspotRating } from './HotspotRating';
+export * from './HtmlFormatter';
export * from './InputField';
export { InputSearch } from './InputSearch';
export * from './InputSelect';
--- /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 { getTabId, getTabPanelId } from '../tabs';
+
+it('should correctly generate IDs', () => {
+ expect(getTabId('ID')).toBe('tab-ID');
+ expect(getTabPanelId('ID')).toBe('tabpanel-ID');
+});
export * from './colors';
export * from './constants';
export * from './positioning';
+export * from './tabs';
export * from './theme';
--- /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.
+ */
+
+export function getTabPanelId(key: string | number) {
+ return `tabpanel-${key}`;
+}
+
+export function getTabId(key: string | number) {
+ return `tab-${key}`;
+}
codeLineLocationMarker: COLORS.red[200],
codeLineLocationMarkerSelected: danger.lighter,
codeLineLocationSelected: COLORS.blueGrey[100],
+ codeLineCoveredUnderline: [...COLORS.green[500], 0.15],
+ codeLineUncoveredUnderline: [...COLORS.red[500], 0.15],
// checkbox
checkboxHover: COLORS.indigo[50],
+++ /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 { cloneDeep, countBy, pick, trim } from 'lodash';
-import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
-import {
- mockCurrentUser,
- mockPaging,
- mockQualityProfile,
- mockRuleDetails,
- mockRuleRepository,
-} from '../../helpers/testMocks';
-import { RuleRepository, SearchRulesResponse } from '../../types/coding-rules';
-import { RawIssuesResponse } from '../../types/issues';
-import { SearchRulesQuery } from '../../types/rules';
-import { Rule, RuleActivation, RuleDetails, RulesUpdateRequest } from '../../types/types';
-import { NoticeType } from '../../types/users';
-import { getFacet } from '../issues';
-import {
- Profile,
- SearchQualityProfilesParameters,
- SearchQualityProfilesResponse,
- bulkActivateRules,
- bulkDeactivateRules,
- searchQualityProfiles,
-} from '../quality-profiles';
-import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
-import { dismissNotice, getCurrentUser } from '../users';
-
-interface FacetFilter {
- languages?: string;
- available_since?: string;
-}
-
-const FACET_RULE_MAP: { [key: string]: keyof Rule } = {
- languages: 'lang',
- types: 'type',
-};
-export default class CodingRulesMock {
- defaultRules: RuleDetails[] = [];
- rules: RuleDetails[] = [];
- qualityProfile: Profile[] = [];
- repositories: RuleRepository[] = [];
- isAdmin = false;
- applyWithWarning = false;
- dismissedNoticesEP = false;
-
- constructor() {
- this.repositories = [
- mockRuleRepository({ key: 'repo1' }),
- mockRuleRepository({ key: 'repo2' }),
- ];
- this.qualityProfile = [
- mockQualityProfile({ key: 'p1', name: 'QP Foo', language: 'java', languageName: 'Java' }),
- mockQualityProfile({ key: 'p2', name: 'QP Bar', language: 'js' }),
- mockQualityProfile({ key: 'p3', name: 'QP FooBar', language: 'java', languageName: 'Java' }),
- ];
-
- const resourceContent = 'Some link <a href="http://example.com">Awsome Reading</a>';
- const introTitle = 'Introduction to this rule';
- const rootCauseContent = 'This how to fix';
-
- this.defaultRules = [
- mockRuleDetails({
- key: 'rule1',
- type: 'BUG',
- lang: 'java',
- langName: 'Java',
- name: 'Awsome java rule',
- }),
- mockRuleDetails({
- key: 'rule2',
- name: 'Hot hotspot',
- type: 'SECURITY_HOTSPOT',
- lang: 'js',
- descriptionSections: [
- { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
- { key: RuleDescriptionSections.ROOT_CAUSE, content: rootCauseContent },
- { key: RuleDescriptionSections.ASSESS_THE_PROBLEM, content: 'Assess' },
- {
- key: RuleDescriptionSections.RESOURCES,
- content: resourceContent,
- },
- ],
- langName: 'JavaScript',
- }),
- mockRuleDetails({ key: 'rule3', name: 'Unknown rule', lang: 'js', langName: 'JavaScript' }),
- mockRuleDetails({
- key: 'rule4',
- type: 'BUG',
- lang: 'c',
- langName: 'C',
- name: 'Awsome C rule',
- }),
- mockRuleDetails({
- key: 'rule5',
- type: 'VULNERABILITY',
- lang: 'py',
- langName: 'Python',
- name: 'Awsome Python rule',
- descriptionSections: [
- { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
- { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
- {
- key: RuleDescriptionSections.RESOURCES,
- content: resourceContent,
- },
- ],
- }),
- mockRuleDetails({
- key: 'rule6',
- type: 'VULNERABILITY',
- lang: 'py',
- langName: 'Python',
- name: 'Bad Python rule',
- isExternal: true,
- descriptionSections: undefined,
- }),
- mockRuleDetails({
- key: 'rule7',
- type: 'VULNERABILITY',
- lang: 'py',
- langName: 'Python',
- name: 'Python rule with context',
- descriptionSections: [
- {
- key: RuleDescriptionSections.INTRODUCTION,
- content: 'Introduction to this rule with context',
- },
- {
- key: RuleDescriptionSections.HOW_TO_FIX,
- content: 'This is how to fix for spring',
- context: { key: 'spring', displayName: 'Spring' },
- },
- {
- key: RuleDescriptionSections.HOW_TO_FIX,
- content: 'This is how to fix for spring boot',
- context: { key: 'spring_boot', displayName: 'Spring boot' },
- },
- {
- key: RuleDescriptionSections.RESOURCES,
- content: resourceContent,
- },
- ],
- }),
- mockRuleDetails({
- createdAt: '2022-12-16T17:26:54+0100',
- key: 'rule8',
- type: 'VULNERABILITY',
- lang: 'py',
- langName: 'Python',
- name: 'Awesome Python rule with education principles',
- descriptionSections: [
- { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
- { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
- {
- key: RuleDescriptionSections.RESOURCES,
- content: resourceContent,
- },
- ],
- educationPrinciples: ['defense_in_depth', 'never_trust_user_input'],
- }),
- ];
-
- (updateRule as jest.Mock).mockImplementation(this.handleUpdateRule);
- (searchRules as jest.Mock).mockImplementation(this.handleSearchRules);
- (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
- (searchQualityProfiles as jest.Mock).mockImplementation(this.handleSearchQualityProfiles);
- (getRulesApp as jest.Mock).mockImplementation(this.handleGetRulesApp);
- (bulkActivateRules as jest.Mock).mockImplementation(this.handleBulkActivateRules);
- (bulkDeactivateRules as jest.Mock).mockImplementation(this.handleBulkDeactivateRules);
- (getFacet as jest.Mock).mockImplementation(this.handleGetGacet);
- (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
- (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
- this.rules = cloneDeep(this.defaultRules);
- }
-
- getRulesWithoutDetails(rules: RuleDetails[]) {
- return rules.map((r) =>
- pick(r, [
- 'isTemplate',
- 'key',
- 'lang',
- 'langName',
- 'name',
- 'params',
- 'severity',
- 'status',
- 'sysTags',
- 'tags',
- 'type',
- ])
- );
- }
-
- filterFacet({ languages, available_since }: FacetFilter) {
- let filteredRules = this.rules;
- if (languages) {
- filteredRules = filteredRules.filter((r) => r.lang && languages.includes(r.lang));
- }
- if (available_since) {
- filteredRules = filteredRules.filter(
- (r) => r.createdAt && new Date(r.createdAt) > new Date(available_since)
- );
- }
- return this.getRulesWithoutDetails(filteredRules);
- }
-
- setIsAdmin() {
- this.isAdmin = true;
- }
-
- activateWithWarning() {
- this.applyWithWarning = true;
- }
-
- reset() {
- this.isAdmin = false;
- this.applyWithWarning = false;
- this.dismissedNoticesEP = false;
- this.rules = cloneDeep(this.defaultRules);
- }
-
- allRulesCount() {
- return this.rules.length;
- }
-
- allRulesName() {
- return this.rules.map((r) => r.name);
- }
-
- allQualityProfile(language: string) {
- return this.qualityProfile.filter((qp) => qp.language === language);
- }
-
- handleGetGacet = (): Promise<{
- facet: { count: number; val: string }[];
- response: RawIssuesResponse;
- }> => {
- return this.reply({
- facet: [],
- response: {
- components: [],
- effortTotal: 0,
- facets: [],
- issues: [],
- languages: [],
- paging: { total: 0, pageIndex: 1, pageSize: 1 },
- },
- });
- };
-
- handleGetRuleDetails = (parameters: {
- actives?: boolean;
- key: string;
- }): Promise<{ actives?: RuleActivation[]; rule: RuleDetails }> => {
- const rule = this.rules.find((r) => r.key === parameters.key);
- if (!rule) {
- return Promise.reject({
- errors: [{ msg: `No rule has been found for id ${parameters.key}` }],
- });
- }
- return this.reply({ actives: parameters.actives ? [] : undefined, rule });
- };
-
- handleUpdateRule = (data: RulesUpdateRequest): Promise<RuleDetails> => {
- const rule = this.rules.find((r) => r.key === data.key);
- if (rule === undefined) {
- return Promise.reject({
- errors: [{ msg: `No rule has been found for id ${data.key}` }],
- });
- }
- const template = this.rules.find((r) => r.key === rule.templateKey);
-
- // Lets not convert the md to html in test.
- rule.mdDesc = data.markdown_description !== undefined ? data.markdown_description : rule.mdDesc;
- rule.htmlDesc =
- data.markdown_description !== undefined ? data.markdown_description : rule.htmlDesc;
- rule.mdNote = data.markdown_note !== undefined ? data.markdown_note : rule.mdNote;
- rule.htmlNote = data.markdown_note !== undefined ? data.markdown_note : rule.htmlNote;
- rule.name = data.name !== undefined ? data.name : rule.name;
- if (template && data.params) {
- rule.params = [];
- data.params.split(';').forEach((param) => {
- const parts = param.split('=');
- const paramsDef = template.params?.find((p) => p.key === parts[0]);
- rule.params?.push({
- key: parts[0],
- type: paramsDef?.type || 'STRING',
- defaultValue: trim(parts[1], '" '),
- htmlDesc: paramsDef?.htmlDesc,
- });
- });
- }
-
- rule.remFnBaseEffort =
- data.remediation_fn_base_effort !== undefined
- ? data.remediation_fn_base_effort
- : rule.remFnBaseEffort;
- rule.remFnType =
- data.remediation_fn_type !== undefined ? data.remediation_fn_type : rule.remFnType;
- rule.severity = data.severity !== undefined ? data.severity : rule.severity;
- rule.status = data.status !== undefined ? data.status : rule.status;
- rule.tags = data.tags !== undefined ? data.tags.split(';') : rule.tags;
-
- return this.reply(rule);
- };
-
- handleSearchRules = ({
- facets,
- languages,
- p,
- ps,
- available_since,
- rule_key,
- }: SearchRulesQuery): Promise<SearchRulesResponse> => {
- const countFacet = (facets || '').split(',').map((facet: keyof Rule) => {
- const facetCount = countBy(
- this.rules.map((r) => r[FACET_RULE_MAP[facet] || facet] as string)
- );
- return {
- property: facet,
- values: Object.keys(facetCount).map((val) => ({ val, count: facetCount[val] })),
- };
- });
- const currentPs = ps || 10;
- const currentP = p || 1;
- let filteredRules: Rule[] = [];
- if (rule_key) {
- filteredRules = this.getRulesWithoutDetails(this.rules).filter((r) => r.key === rule_key);
- } else {
- filteredRules = this.filterFacet({ languages, available_since });
- }
- const responseRules = filteredRules.slice((currentP - 1) * currentPs, currentP * currentPs);
- return this.reply({
- rules: responseRules,
- facets: countFacet,
- paging: mockPaging({
- total: filteredRules.length,
- pageIndex: currentP,
- pageSize: currentPs,
- }),
- });
- };
-
- handleBulkActivateRules = () => {
- if (this.applyWithWarning) {
- return this.reply({
- succeeded: this.rules.length - 1,
- failed: 1,
- errors: [{ msg: 'c rule c:S6069 cannot be activated on cpp profile SonarSource' }],
- });
- }
- return this.reply({
- succeeded: this.rules.length,
- failed: 0,
- errors: [],
- });
- };
-
- handleBulkDeactivateRules = () => {
- return this.reply({
- succeeded: this.rules.length,
- failed: 0,
- });
- };
-
- handleSearchQualityProfiles = ({
- language,
- }: SearchQualityProfilesParameters = {}): Promise<SearchQualityProfilesResponse> => {
- let profiles: Profile[] = this.isAdmin
- ? this.qualityProfile.map((p) => ({ ...p, actions: { edit: true } }))
- : this.qualityProfile;
- if (language) {
- profiles = profiles.filter((p) => p.language === language);
- }
- return this.reply({ profiles });
- };
-
- handleGetRulesApp = () => {
- return this.reply({ canWrite: this.isAdmin, repositories: this.repositories });
- };
-
- handleGetCurrentUser = () => {
- return this.reply(
- mockCurrentUser({
- dismissedNotices: {
- educationPrinciples: this.dismissedNoticesEP,
- },
- })
- );
- };
-
- handleDismissNotification = (noticeType: NoticeType) => {
- if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
- this.dismissedNoticesEP = true;
- return this.reply(true);
- }
-
- return Promise.reject();
- };
-
- reply<T>(response: T): Promise<T> {
- return Promise.resolve(cloneDeep(response));
- }
-}
--- /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 { cloneDeep, countBy, pick, trim } from 'lodash';
+import { RuleDescriptionSections } from '../../apps/coding-rules/rule';
+import {
+ mockCurrentUser,
+ mockPaging,
+ mockQualityProfile,
+ mockRuleDetails,
+ mockRuleRepository,
+} from '../../helpers/testMocks';
+import { RuleRepository, SearchRulesResponse } from '../../types/coding-rules';
+import { RawIssuesResponse } from '../../types/issues';
+import { SearchRulesQuery } from '../../types/rules';
+import { Rule, RuleActivation, RuleDetails, RulesUpdateRequest } from '../../types/types';
+import { NoticeType } from '../../types/users';
+import { getFacet } from '../issues';
+import {
+ Profile,
+ SearchQualityProfilesParameters,
+ SearchQualityProfilesResponse,
+ bulkActivateRules,
+ bulkDeactivateRules,
+ searchQualityProfiles,
+} from '../quality-profiles';
+import { getRuleDetails, getRulesApp, searchRules, updateRule } from '../rules';
+import { dismissNotice, getCurrentUser } from '../users';
+
+interface FacetFilter {
+ languages?: string;
+ available_since?: string;
+}
+
+const FACET_RULE_MAP: { [key: string]: keyof Rule } = {
+ languages: 'lang',
+ types: 'type',
+};
+
+export default class CodingRulesServiceMock {
+ defaultRules: RuleDetails[] = [];
+ rules: RuleDetails[] = [];
+ qualityProfile: Profile[] = [];
+ repositories: RuleRepository[] = [];
+ isAdmin = false;
+ applyWithWarning = false;
+ dismissedNoticesEP = false;
+
+ constructor() {
+ this.repositories = [
+ mockRuleRepository({ key: 'repo1' }),
+ mockRuleRepository({ key: 'repo2' }),
+ ];
+ this.qualityProfile = [
+ mockQualityProfile({ key: 'p1', name: 'QP Foo', language: 'java', languageName: 'Java' }),
+ mockQualityProfile({ key: 'p2', name: 'QP Bar', language: 'js' }),
+ mockQualityProfile({ key: 'p3', name: 'QP FooBar', language: 'java', languageName: 'Java' }),
+ ];
+
+ const resourceContent = 'Some link <a href="http://example.com">Awsome Reading</a>';
+ const introTitle = 'Introduction to this rule';
+ const rootCauseContent = 'Root cause';
+ const howToFixContent = 'This is how to fix';
+
+ this.defaultRules = [
+ mockRuleDetails({
+ key: 'rule1',
+ type: 'BUG',
+ lang: 'java',
+ langName: 'Java',
+ name: 'Awsome java rule',
+ }),
+ mockRuleDetails({
+ key: 'rule2',
+ name: 'Hot hotspot',
+ type: 'SECURITY_HOTSPOT',
+ lang: 'js',
+ descriptionSections: [
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.ROOT_CAUSE, content: rootCauseContent },
+ { key: RuleDescriptionSections.HOW_TO_FIX, content: howToFixContent },
+ { key: RuleDescriptionSections.ASSESS_THE_PROBLEM, content: 'Assess' },
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: resourceContent,
+ },
+ ],
+ langName: 'JavaScript',
+ }),
+ mockRuleDetails({ key: 'rule3', name: 'Unknown rule', lang: 'js', langName: 'JavaScript' }),
+ mockRuleDetails({
+ key: 'rule4',
+ type: 'BUG',
+ lang: 'c',
+ langName: 'C',
+ name: 'Awsome C rule',
+ }),
+ mockRuleDetails({
+ key: 'rule5',
+ type: 'VULNERABILITY',
+ lang: 'py',
+ langName: 'Python',
+ name: 'Awsome Python rule',
+ descriptionSections: [
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: resourceContent,
+ },
+ ],
+ }),
+ mockRuleDetails({
+ key: 'rule6',
+ type: 'VULNERABILITY',
+ lang: 'py',
+ langName: 'Python',
+ name: 'Bad Python rule',
+ isExternal: true,
+ descriptionSections: undefined,
+ }),
+ mockRuleDetails({
+ key: 'rule7',
+ type: 'VULNERABILITY',
+ lang: 'py',
+ langName: 'Python',
+ name: 'Python rule with context',
+ descriptionSections: [
+ {
+ key: RuleDescriptionSections.INTRODUCTION,
+ content: 'Introduction to this rule with context',
+ },
+ {
+ key: RuleDescriptionSections.HOW_TO_FIX,
+ content: 'This is how to fix for spring',
+ context: { key: 'spring', displayName: 'Spring' },
+ },
+ {
+ key: RuleDescriptionSections.HOW_TO_FIX,
+ content: 'This is how to fix for spring boot',
+ context: { key: 'spring_boot', displayName: 'Spring boot' },
+ },
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: resourceContent,
+ },
+ ],
+ }),
+ mockRuleDetails({
+ createdAt: '2022-12-16T17:26:54+0100',
+ key: 'rule8',
+ type: 'VULNERABILITY',
+ lang: 'py',
+ langName: 'Python',
+ name: 'Awesome Python rule with education principles',
+ descriptionSections: [
+ { key: RuleDescriptionSections.INTRODUCTION, content: introTitle },
+ { key: RuleDescriptionSections.HOW_TO_FIX, content: rootCauseContent },
+ {
+ key: RuleDescriptionSections.RESOURCES,
+ content: resourceContent,
+ },
+ ],
+ educationPrinciples: ['defense_in_depth', 'never_trust_user_input'],
+ }),
+ ];
+
+ (updateRule as jest.Mock).mockImplementation(this.handleUpdateRule);
+ (searchRules as jest.Mock).mockImplementation(this.handleSearchRules);
+ (getRuleDetails as jest.Mock).mockImplementation(this.handleGetRuleDetails);
+ (searchQualityProfiles as jest.Mock).mockImplementation(this.handleSearchQualityProfiles);
+ (getRulesApp as jest.Mock).mockImplementation(this.handleGetRulesApp);
+ (bulkActivateRules as jest.Mock).mockImplementation(this.handleBulkActivateRules);
+ (bulkDeactivateRules as jest.Mock).mockImplementation(this.handleBulkDeactivateRules);
+ (getFacet as jest.Mock).mockImplementation(this.handleGetGacet);
+ (getCurrentUser as jest.Mock).mockImplementation(this.handleGetCurrentUser);
+ (dismissNotice as jest.Mock).mockImplementation(this.handleDismissNotification);
+ this.rules = cloneDeep(this.defaultRules);
+ }
+
+ getRulesWithoutDetails(rules: RuleDetails[]) {
+ return rules.map((r) =>
+ pick(r, [
+ 'isTemplate',
+ 'key',
+ 'lang',
+ 'langName',
+ 'name',
+ 'params',
+ 'severity',
+ 'status',
+ 'sysTags',
+ 'tags',
+ 'type',
+ ])
+ );
+ }
+
+ filterFacet({ languages, available_since }: FacetFilter) {
+ let filteredRules = this.rules;
+ if (languages) {
+ filteredRules = filteredRules.filter((r) => r.lang && languages.includes(r.lang));
+ }
+ if (available_since) {
+ filteredRules = filteredRules.filter(
+ (r) => r.createdAt && new Date(r.createdAt) > new Date(available_since)
+ );
+ }
+ return this.getRulesWithoutDetails(filteredRules);
+ }
+
+ setIsAdmin() {
+ this.isAdmin = true;
+ }
+
+ activateWithWarning() {
+ this.applyWithWarning = true;
+ }
+
+ reset() {
+ this.isAdmin = false;
+ this.applyWithWarning = false;
+ this.dismissedNoticesEP = false;
+ this.rules = cloneDeep(this.defaultRules);
+ }
+
+ allRulesCount() {
+ return this.rules.length;
+ }
+
+ allRulesName() {
+ return this.rules.map((r) => r.name);
+ }
+
+ allQualityProfile(language: string) {
+ return this.qualityProfile.filter((qp) => qp.language === language);
+ }
+
+ handleGetGacet = (): Promise<{
+ facet: { count: number; val: string }[];
+ response: RawIssuesResponse;
+ }> => {
+ return this.reply({
+ facet: [],
+ response: {
+ components: [],
+ effortTotal: 0,
+ facets: [],
+ issues: [],
+ languages: [],
+ paging: { total: 0, pageIndex: 1, pageSize: 1 },
+ },
+ });
+ };
+
+ handleGetRuleDetails = (parameters: {
+ actives?: boolean;
+ key: string;
+ }): Promise<{ actives?: RuleActivation[]; rule: RuleDetails }> => {
+ const rule = this.rules.find((r) => r.key === parameters.key);
+ if (!rule) {
+ return Promise.reject({
+ errors: [{ msg: `No rule has been found for id ${parameters.key}` }],
+ });
+ }
+ return this.reply({ actives: parameters.actives ? [] : undefined, rule });
+ };
+
+ handleUpdateRule = (data: RulesUpdateRequest): Promise<RuleDetails> => {
+ const rule = this.rules.find((r) => r.key === data.key);
+ if (rule === undefined) {
+ return Promise.reject({
+ errors: [{ msg: `No rule has been found for id ${data.key}` }],
+ });
+ }
+ const template = this.rules.find((r) => r.key === rule.templateKey);
+
+ // Lets not convert the md to html in test.
+ rule.mdDesc = data.markdown_description !== undefined ? data.markdown_description : rule.mdDesc;
+ rule.htmlDesc =
+ data.markdown_description !== undefined ? data.markdown_description : rule.htmlDesc;
+ rule.mdNote = data.markdown_note !== undefined ? data.markdown_note : rule.mdNote;
+ rule.htmlNote = data.markdown_note !== undefined ? data.markdown_note : rule.htmlNote;
+ rule.name = data.name !== undefined ? data.name : rule.name;
+ if (template && data.params) {
+ rule.params = [];
+ data.params.split(';').forEach((param) => {
+ const parts = param.split('=');
+ const paramsDef = template.params?.find((p) => p.key === parts[0]);
+ rule.params?.push({
+ key: parts[0],
+ type: paramsDef?.type || 'STRING',
+ defaultValue: trim(parts[1], '" '),
+ htmlDesc: paramsDef?.htmlDesc,
+ });
+ });
+ }
+
+ rule.remFnBaseEffort =
+ data.remediation_fn_base_effort !== undefined
+ ? data.remediation_fn_base_effort
+ : rule.remFnBaseEffort;
+ rule.remFnType =
+ data.remediation_fn_type !== undefined ? data.remediation_fn_type : rule.remFnType;
+ rule.severity = data.severity !== undefined ? data.severity : rule.severity;
+ rule.status = data.status !== undefined ? data.status : rule.status;
+ rule.tags = data.tags !== undefined ? data.tags.split(';') : rule.tags;
+
+ return this.reply(rule);
+ };
+
+ handleSearchRules = ({
+ facets,
+ languages,
+ p,
+ ps,
+ available_since,
+ rule_key,
+ }: SearchRulesQuery): Promise<SearchRulesResponse> => {
+ const countFacet = (facets || '').split(',').map((facet: keyof Rule) => {
+ const facetCount = countBy(
+ this.rules.map((r) => r[FACET_RULE_MAP[facet] || facet] as string)
+ );
+ return {
+ property: facet,
+ values: Object.keys(facetCount).map((val) => ({ val, count: facetCount[val] })),
+ };
+ });
+ const currentPs = ps || 10;
+ const currentP = p || 1;
+ let filteredRules: Rule[] = [];
+ if (rule_key) {
+ filteredRules = this.getRulesWithoutDetails(this.rules).filter((r) => r.key === rule_key);
+ } else {
+ filteredRules = this.filterFacet({ languages, available_since });
+ }
+ const responseRules = filteredRules.slice((currentP - 1) * currentPs, currentP * currentPs);
+ return this.reply({
+ rules: responseRules,
+ facets: countFacet,
+ paging: mockPaging({
+ total: filteredRules.length,
+ pageIndex: currentP,
+ pageSize: currentPs,
+ }),
+ });
+ };
+
+ handleBulkActivateRules = () => {
+ if (this.applyWithWarning) {
+ return this.reply({
+ succeeded: this.rules.length - 1,
+ failed: 1,
+ errors: [{ msg: 'c rule c:S6069 cannot be activated on cpp profile SonarSource' }],
+ });
+ }
+ return this.reply({
+ succeeded: this.rules.length,
+ failed: 0,
+ errors: [],
+ });
+ };
+
+ handleBulkDeactivateRules = () => {
+ return this.reply({
+ succeeded: this.rules.length,
+ failed: 0,
+ });
+ };
+
+ handleSearchQualityProfiles = ({
+ language,
+ }: SearchQualityProfilesParameters = {}): Promise<SearchQualityProfilesResponse> => {
+ let profiles: Profile[] = this.isAdmin
+ ? this.qualityProfile.map((p) => ({ ...p, actions: { edit: true } }))
+ : this.qualityProfile;
+ if (language) {
+ profiles = profiles.filter((p) => p.language === language);
+ }
+ return this.reply({ profiles });
+ };
+
+ handleGetRulesApp = () => {
+ return this.reply({ canWrite: this.isAdmin, repositories: this.repositories });
+ };
+
+ handleGetCurrentUser = () => {
+ return this.reply(
+ mockCurrentUser({
+ dismissedNotices: {
+ educationPrinciples: this.dismissedNoticesEP,
+ },
+ })
+ );
+ };
+
+ handleDismissNotification = (noticeType: NoticeType) => {
+ if (noticeType === NoticeType.EDUCATION_PRINCIPLES) {
+ this.dismissedNoticesEP = true;
+ return this.reply(true);
+ }
+
+ return Promise.reject();
+ };
+
+ reply<T>(response: T): Promise<T> {
+ return Promise.resolve(cloneDeep(response));
+ }
+}
import {
mockHotspot,
mockHotspotComment,
+ mockHotspotRule,
mockRawHotspot,
mockStandards,
} from '../../helpers/mocks/security-hotspots';
import { mockSourceLine } from '../../helpers/mocks/sources';
import { getStandards } from '../../helpers/security-standard';
-import { mockPaging, mockRuleDetails, mockUser } from '../../helpers/testMocks';
+import { mockPaging, mockUser } from '../../helpers/testMocks';
import {
Hotspot,
HotspotAssignRequest,
} from '../../types/security-hotspots';
import { getSources } from '../components';
import { getMeasures } from '../measures';
-import { getRuleDetails } from '../rules';
import {
assignSecurityHotspot,
commentSecurityHotspot,
jest.mocked(assignSecurityHotspot).mockImplementation(this.handleAssignSecurityHotspot);
jest.mocked(setSecurityHotspotStatus).mockImplementation(this.handleSetSecurityHotspotStatus);
jest.mocked(searchUsers).mockImplementation(this.handleSearchUsers);
- jest.mocked(getRuleDetails).mockResolvedValue({ rule: mockRuleDetails() });
jest.mocked(getSources).mockResolvedValue(
times(NUMBER_OF_LINES, (n) =>
mockSourceLine({
reset = () => {
this.hotspots = [
mockHotspot({
+ rule: mockHotspotRule({ key: 'rule2' }),
assignee: 'John Doe',
key: 'b1-test-1',
message: "'F' is a magic number.",
}),
- mockHotspot({ assignee: 'John Doe', key: 'b1-test-2' }),
- mockHotspot({ key: 'test-1', status: HotspotStatus.TO_REVIEW }),
mockHotspot({
+ rule: mockHotspotRule({ key: 'rule2' }),
+ assignee: 'John Doe',
+ key: 'b1-test-2',
+ }),
+ mockHotspot({
+ rule: mockHotspotRule({ key: 'rule2' }),
+ key: 'test-1',
+ status: HotspotStatus.TO_REVIEW,
+ }),
+ mockHotspot({
+ rule: mockHotspotRule({ key: 'rule2' }),
key: 'test-2',
status: HotspotStatus.TO_REVIEW,
message: "'2' is a magic number.",
}
.rule-desc pre,
-.markdown pre,
-.code-difference-scrollable {
+.markdown pre {
background-color: var(--codeBackground);
border-radius: 8px;
border: 1px solid var(--codeBorder);
overflow-x: auto;
}
-.code-difference-container {
- display: flex;
- flex-direction: column;
- width: fit-content;
- min-width: 100%;
-}
-
-.code-difference-scrollable .code-added {
- background-color: var(--codeAdded);
- padding-left: calc(2 * var(--gridSize));
- padding-right: calc(2 * var(--gridSize));
- margin-left: calc(-2 * var(--gridSize));
- margin-right: calc(-2 * var(--gridSize));
- border-radius: 0;
-}
-
-.code-difference-scrollable .code-removed {
- background-color: var(--codeRemoved);
- padding-left: calc(2 * var(--gridSize));
- padding-right: calc(2 * var(--gridSize));
- margin-left: calc(-2 * var(--gridSize));
- margin-right: calc(-2 * var(--gridSize));
- border-radius: 0;
-}
-
.rule-desc code,
.markdown code,
code.rule {
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { byPlaceholderText, byRole } from 'testing-library-selector';
-import CodingRulesMock from '../../../api/mocks/CodingRulesMock';
+import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
import { mockCurrentUser, mockLoggedInUser } from '../../../helpers/testMocks';
import { renderAppRoutes } from '../../../helpers/testReactTestingUtils';
import { CurrentUser } from '../../../types/users';
availableSinceDateField: byPlaceholderText('date'),
};
-let handler: CodingRulesMock;
+let handler: CodingRulesServiceMock;
beforeAll(() => {
window.scrollTo = jest.fn();
window.HTMLElement.prototype.scrollIntoView = jest.fn();
- handler = new CodingRulesMock();
+ handler = new CodingRulesServiceMock();
});
afterEach(() => handler.reset());
padding-left: 20px;
}
-.rules-context-description ul {
- padding: 0px;
-}
-
-.rules-context-description h2.rule-contexts-title {
- border: 0px;
-}
-
.notice-dot {
height: var(--gridSize);
width: var(--gridSize);
/>
<LargeCenteredLayout id={MetricKey.security_hotspots}>
<PageContentFontWrapper>
- <div className="sw-grid sw-grid-cols-12 sw-w-full">
+ <div className="sw-grid sw-grid-cols-12 sw-w-full sw-body-sm">
<DeferredSpinner className="sw-mt-3" loading={loading} />
{!loading &&
import { Route } from 'react-router-dom';
import selectEvent from 'react-select-event';
import { byDisplayValue, byRole, byTestId, byText } from 'testing-library-selector';
+import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock';
import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock';
import { getSecurityHotspots, setSecurityHotspotStatus } from '../../../api/security-hotspots';
import { searchUsers } from '../../../api/users';
jest.mock('../../../api/measures');
jest.mock('../../../api/security-hotspots');
-jest.mock('../../../api/rules');
jest.mock('../../../api/components');
jest.mock('../../../helpers/security-standard');
jest.mock('../../../api/users');
+jest.mock('../../../api/rules');
+jest.mock('../../../api/quality-profiles');
+jest.mock('../../../api/issues');
+
const ui = {
inputAssignee: byRole('searchbox', { name: 'hotspots.assignee.select_user' }),
selectStatusButton: byRole('button', {
successGlobalMessage: byRole('status'),
currentUserSelectionItem: byText('foo'),
panel: byTestId('security-hotspot-test'),
+ codeTab: byRole('tab', { name: 'hotspots.tabs.code' }),
+ codeContent: byRole('table'),
+ riskTab: byRole('tab', { name: 'hotspots.tabs.risk_description' }),
+ riskContent: byText('Root cause'),
+ vulnerabilityTab: byRole('tab', { name: 'hotspots.tabs.vulnerability_description' }),
+ vulnerabilityContent: byText('Assess'),
+ fixTab: byRole('tab', { name: 'hotspots.tabs.fix_recommendations' }),
+ fixContent: byText('This is how to fix'),
};
-let handler: SecurityHotspotServiceMock;
-
-beforeEach(() => {
- handler = new SecurityHotspotServiceMock();
-});
+const hotspotsHandler = new SecurityHotspotServiceMock();
+const rulesHandles = new CodingRulesServiceMock();
afterEach(() => {
- handler.reset();
+ hotspotsHandler.reset();
+ rulesHandles.reset();
});
describe('rendering', () => {
expect(await ui.hotspotTitle(/'F' is a magic number./).find()).toBeInTheDocument();
});
-it('should be able to self-assign a hotspot', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
+describe('CRUD', () => {
+ it('should be able to self-assign a hotspot', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
- expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
+ expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
- await user.click(ui.editAssigneeButton.get());
- await user.click(ui.currentUserSelectionItem.get());
+ await user.click(ui.editAssigneeButton.get());
+ await user.click(ui.currentUserSelectionItem.get());
- expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
- expect(ui.activeAssignee.get()).toHaveTextContent('foo');
-});
+ expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
+ expect(ui.activeAssignee.get()).toHaveTextContent('foo');
+ });
-it('should be able to search for a user on the assignee', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
+ it('should be able to search for a user on the assignee', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
- await user.click(await ui.editAssigneeButton.find());
- await user.click(ui.inputAssignee.get());
+ await user.click(await ui.editAssigneeButton.find());
+ await user.click(ui.inputAssignee.get());
- await user.keyboard('User');
+ await user.keyboard('User');
- expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
- await user.keyboard('{ArrowDown}{Enter}');
- expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
-});
+ expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
+ await user.keyboard('{ArrowDown}{Enter}');
+ expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
+ });
-it('should be able to filter the hotspot list', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
+ it('should be able to change the status of a hotspot', async () => {
+ const user = userEvent.setup();
+ const comment = 'COMMENT-TEXT';
- expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
+ renderSecurityHotspotsApp();
- await user.click(ui.filterAssigneeToMe.get());
- expect(ui.noHotspotForFilter.get()).toBeInTheDocument();
- await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']);
+ expect(await ui.selectStatus.find()).toBeInTheDocument();
- expect(getSecurityHotspots).toHaveBeenLastCalledWith({
- inNewCodePeriod: false,
- onlyMine: true,
- p: 1,
- projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
- ps: 500,
- resolution: undefined,
- status: 'TO_REVIEW',
- });
+ await user.click(ui.selectStatus.get());
+ await user.click(ui.toReviewStatus.get());
- await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']);
+ await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
+ await user.keyboard(comment);
- expect(getSecurityHotspots).toHaveBeenLastCalledWith({
- inNewCodePeriod: true,
- onlyMine: true,
- p: 1,
- projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
- ps: 500,
- resolution: undefined,
- status: 'TO_REVIEW',
- });
+ await act(async () => {
+ await user.click(ui.changeStatus.get());
+ });
- await user.click(ui.filterSeeAll.get());
+ expect(setSecurityHotspotStatus).toHaveBeenLastCalledWith('test-1', {
+ comment: 'COMMENT-TEXT',
+ resolution: undefined,
+ status: 'TO_REVIEW',
+ });
- expect(ui.hotpostListTitle.get()).toBeInTheDocument();
-});
+ expect(ui.hotspotStatus.get()).toBeInTheDocument();
+ });
-it('should be able to navigate the hotspot list with keyboard', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
+ it('should not be able to change the status if does not have edit permissions', async () => {
+ hotspotsHandler.setHotspotChangeStatusPermission(false);
+ renderSecurityHotspotsApp();
+ expect(await ui.selectStatus.find()).toBeDisabled();
+ });
- await user.keyboard('{ArrowDown}');
- expect(await ui.hotspotTitle(/'2' is a magic number./).find()).toBeInTheDocument();
- await user.keyboard('{ArrowUp}');
- expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
-});
+ it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
-it('should be able to change the status of a hotspot', async () => {
- const user = userEvent.setup();
- const comment = 'COMMENT-TEXT';
+ await user.click(await ui.selectStatusButton.find());
+ const comment = 'This is a comment';
- renderSecurityHotspotsApp();
+ const commentSection = within(ui.panel.get()).getByRole('textbox');
+ await user.click(commentSection);
+ await user.keyboard(comment);
- expect(await ui.selectStatus.find()).toBeInTheDocument();
+ // Close the panel
+ await act(async () => {
+ await user.keyboard('{Escape}');
+ });
- await user.click(ui.selectStatus.get());
- await user.click(ui.toReviewStatus.get());
+ // Check panel is closed
+ expect(ui.panel.query()).not.toBeInTheDocument();
- await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
- await user.keyboard(comment);
+ await user.click(ui.selectStatusButton.get());
- await act(async () => {
- await user.click(ui.changeStatus.get());
+ expect(await screen.findByText(comment)).toBeInTheDocument();
});
- expect(setSecurityHotspotStatus).toHaveBeenLastCalledWith('test-1', {
- comment: 'COMMENT-TEXT',
- resolution: undefined,
- status: 'TO_REVIEW',
- });
+ it('should be able to add, edit and remove own comments', async () => {
+ const uiComment = {
+ saveButton: byRole('button', { name: 'save' }),
+ deleteButton: byRole('button', { name: 'delete' }),
+ };
+ const user = userEvent.setup();
+ const comment = 'This is a comment from john doe';
+ renderSecurityHotspotsApp();
- expect(ui.hotspotStatus.get()).toBeInTheDocument();
-});
+ const commentSection = await ui.hotspotCommentBox.find();
+ const submitButton = ui.commentSubmitButton.get();
-it('should not be able to change the status if does not have edit permissions', async () => {
- handler.setHotspotChangeStatusPermission(false);
- renderSecurityHotspotsApp();
- expect(await ui.selectStatus.find()).toBeDisabled();
+ // Add a new comment
+ await user.click(commentSection);
+ await user.keyboard(comment);
+ await user.click(submitButton);
+
+ expect(await screen.findByText(comment)).toBeInTheDocument();
+
+ // Edit the comment
+ await user.click(ui.commentEditButton.get());
+ await user.click(ui.textboxWithText(comment).get());
+ await user.keyboard(' test');
+ await user.click(uiComment.saveButton.get());
+
+ expect(await byText(`${comment} test`).find()).toBeInTheDocument();
+
+ // Delete the comment
+ await user.click(ui.commentDeleteButton.get());
+ await user.click(uiComment.deleteButton.get());
+
+ expect(screen.queryByText(`${comment} test`)).not.toBeInTheDocument();
+ });
});
-it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
+describe('navigation', () => {
+ it('should correctly handle tabs', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
+
+ await user.click(await ui.riskTab.find());
+ expect(ui.riskContent.get()).toBeInTheDocument();
- await user.click(await ui.selectStatusButton.find());
+ await user.click(ui.vulnerabilityTab.get());
+ expect(ui.vulnerabilityContent.get()).toBeInTheDocument();
- const comment = 'This is a comment';
+ await user.click(ui.fixTab.get());
+ expect(ui.fixContent.get()).toBeInTheDocument();
- const commentSection = within(ui.panel.get()).getByRole('textbox');
- await user.click(commentSection);
- await user.keyboard(comment);
+ await user.click(ui.codeTab.get());
+ expect(ui.codeContent.get()).toHaveClass('source-table');
+ });
+
+ it('should be able to navigate the hotspot list with keyboard', async () => {
+ const user = userEvent.setup();
+ renderSecurityHotspotsApp();
- // Close the panel
- await act(async () => {
- await user.keyboard('{Escape}');
+ await user.keyboard('{ArrowDown}');
+ expect(await ui.hotspotTitle(/'2' is a magic number./).find()).toBeInTheDocument();
+ await user.keyboard('{ArrowUp}');
+ expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
});
- // Check panel is closed
- expect(ui.panel.query()).not.toBeInTheDocument();
+ it('should navigate when coming from SonarLint', async () => {
+ // On main branch
+ const rtl = renderSecurityHotspotsApp(
+ 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-1'
+ );
+
+ expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument();
- await user.click(ui.selectStatusButton.get());
+ // On specific branch
+ rtl.unmount();
+ renderSecurityHotspotsApp(
+ 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=b1-test-1&branch=b1',
+ { branchLike: mockBranch({ name: 'b1' }) }
+ );
- expect(await screen.findByText(comment)).toBeInTheDocument();
+ expect(await ui.hotspotTitle(/'F' is a magic number./).find()).toBeInTheDocument();
+ });
});
-it('should be able to add, edit and remove own comments', async () => {
- const uiComment = {
- saveButton: byRole('button', { name: 'save' }),
- deleteButton: byRole('button', { name: 'delete' }),
- };
+it('should be able to filter the hotspot list', async () => {
const user = userEvent.setup();
- const comment = 'This is a comment from john doe';
renderSecurityHotspotsApp();
- const commentSection = await ui.hotspotCommentBox.find();
- const submitButton = ui.commentSubmitButton.get();
+ expect(await ui.hotpostListTitle.find()).toBeInTheDocument();
- // Add a new comment
- await user.click(commentSection);
- await user.keyboard(comment);
- await user.click(submitButton);
+ await user.click(ui.filterAssigneeToMe.get());
+ expect(ui.noHotspotForFilter.get()).toBeInTheDocument();
+ await selectEvent.select(ui.filterByStatus.get(), ['hotspot.filters.status.to_review']);
- expect(await screen.findByText(comment)).toBeInTheDocument();
+ expect(getSecurityHotspots).toHaveBeenLastCalledWith({
+ inNewCodePeriod: false,
+ onlyMine: true,
+ p: 1,
+ projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+ ps: 500,
+ resolution: undefined,
+ status: 'TO_REVIEW',
+ });
- // Edit the comment
- await user.click(ui.commentEditButton.get());
- await user.click(ui.textboxWithText(comment).get());
- await user.keyboard(' test');
- await user.click(uiComment.saveButton.get());
+ await selectEvent.select(ui.filterByPeriod.get(), ['hotspot.filters.period.since_leak_period']);
- expect(await byText(`${comment} test`).find()).toBeInTheDocument();
+ expect(getSecurityHotspots).toHaveBeenLastCalledWith({
+ inNewCodePeriod: true,
+ onlyMine: true,
+ p: 1,
+ projectKey: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed',
+ ps: 500,
+ resolution: undefined,
+ status: 'TO_REVIEW',
+ });
- // Delete the comment
- await user.click(ui.commentDeleteButton.get());
- await user.click(uiComment.deleteButton.get());
+ await user.click(ui.filterSeeAll.get());
- expect(screen.queryByText(`${comment} test`)).not.toBeInTheDocument();
+ expect(ui.hotpostListTitle.get()).toBeInTheDocument();
});
function renderSecurityHotspotsApp(
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { ToggleButton, getTabId, getTabPanelId } from 'design-system';
import { groupBy } from 'lodash';
import * as React from 'react';
-import BoxedTabs, { getTabId, getTabPanelId } from '../../../components/controls/BoxedTabs';
import RuleDescription from '../../../components/rules/RuleDescription';
import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
import { KeyboardKeys } from '../../../helpers/keycodes';
}
interface Tab {
- key: TabKeys;
- label: React.ReactNode;
+ value: TabKeys;
+ label: string;
content: React.ReactNode;
}
RiskDescription = 'risk',
VulnerabilityDescription = 'vulnerability',
FixRecommendation = 'fix',
+ Activity = 'activity',
}
export default class HotspotViewerTabs extends React.PureComponent<Props, State> {
handleSelectTabs = (tabKey: TabKeys) => {
const { tabs } = this.state;
- const currentTab = tabs.find((tab) => tab.key === tabKey)!;
- this.setState({ currentTab });
+ const currentTab = tabs.find((tab) => tab.value === tabKey);
+ if (currentTab) {
+ this.setState({ currentTab });
+ }
};
computeTabs() {
return [
{
- key: TabKeys.Code,
+ value: TabKeys.Code,
label: translate('hotspots.tabs.code'),
- content: <div className="padded">{codeTabContent}</div>,
+ content: codeTabContent,
},
{
- key: TabKeys.RiskDescription,
+ value: TabKeys.RiskDescription,
label: translate('hotspots.tabs.risk_description'),
content: rootCauseDescriptionSections && (
- <RuleDescription
- className="big-padded"
- sections={rootCauseDescriptionSections}
- isDefault={true}
- />
+ <RuleDescription sections={rootCauseDescriptionSections} />
),
},
{
- key: TabKeys.VulnerabilityDescription,
+ value: TabKeys.VulnerabilityDescription,
label: translate('hotspots.tabs.vulnerability_description'),
content: descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM] && (
<RuleDescription
- className="big-padded"
sections={descriptionSectionsByKey[RuleDescriptionSections.ASSESS_THE_PROBLEM]}
- isDefault={true}
/>
),
},
{
- key: TabKeys.FixRecommendation,
+ value: TabKeys.FixRecommendation,
label: translate('hotspots.tabs.fix_recommendations'),
content: descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX] && (
<RuleDescription
- className="big-padded"
sections={descriptionSectionsByKey[RuleDescriptionSections.HOW_TO_FIX]}
- isDefault={true}
/>
),
},
selectNeighboringTab(shift: number) {
this.setState(({ tabs, currentTab }) => {
- const index = currentTab && tabs.findIndex((tab) => tab.key === currentTab.key);
+ const index = currentTab && tabs.findIndex((tab) => tab.value === currentTab.value);
if (index !== undefined && index > -1) {
const newIndex = Math.max(0, Math.min(tabs.length - 1, index + shift));
const { tabs, currentTab } = this.state;
return (
<>
- <BoxedTabs onSelect={this.handleSelectTabs} selected={currentTab.key} tabs={tabs} />
+ <ToggleButton
+ role="tablist"
+ value={currentTab.value}
+ options={tabs}
+ onChange={this.handleSelectTabs}
+ />
<div
- className="bordered huge-spacer-bottom"
+ aria-labelledby={getTabId(currentTab.value)}
+ className="sw-mt-6"
+ id={getTabPanelId(currentTab.value)}
role="tabpanel"
- aria-labelledby={getTabId(currentTab.key)}
- id={getTabPanelId(currentTab.key)}
>
{currentTab.content}
</div>
* 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 styled from '@emotion/styled';
+import { HtmlFormatter, themeBorder, themeColor } from 'design-system';
import * as React from 'react';
import { RuleDescriptionSection } from '../../apps/coding-rules/rule';
import applyCodeDifferences from '../../helpers/code-difference';
const OTHERS_KEY = 'others';
interface Props {
- isDefault?: boolean;
sections: RuleDescriptionSection[];
defaultContextKey?: string;
className?: string;
};
render() {
- const { className, sections, isDefault } = this.props;
+ const { className, sections } = this.props;
const { contexts, defaultContext, selectedContext } = this.state;
const options = contexts.map((ctxt) => ({
if (contexts.length > 0 && selectedContext) {
return (
- <div
- className={classNames(className, {
- markdown: isDefault,
- 'rule-desc': !isDefault,
- })}
+ <StyledHtmlFormatter
+ className={className}
ref={(node) => {
applyCodeDifferences(node);
}}
>
- <div className="rules-context-description">
- <h2 className="rule-contexts-title">
- {translate('coding_rules.description_context.title')}
- </h2>
- {defaultContext && (
- <Alert variant="info" display="inline" className="big-spacer-bottom">
+ <h2 className="sw-body-sm-highlight sw-mb-4">
+ {translate('coding_rules.description_context.title')}
+ </h2>
+ {defaultContext && (
+ <Alert variant="info" display="inline" className="big-spacer-bottom">
+ {translateWithParameters(
+ 'coding_rules.description_context.default_information',
+ defaultContext.displayName
+ )}
+ </Alert>
+ )}
+ <div className="big-spacer-bottom">
+ <ButtonToggle
+ label={translate('coding_rules.description_context.title')}
+ onCheck={this.handleToggleContext}
+ options={options}
+ value={selectedContext.displayName}
+ />
+ {selectedContext.key !== OTHERS_KEY && (
+ <h2>
{translateWithParameters(
- 'coding_rules.description_context.default_information',
- defaultContext.displayName
+ 'coding_rules.description_context.sub_title',
+ selectedContext.displayName
)}
- </Alert>
- )}
- <div className="big-spacer-bottom">
- <ButtonToggle
- label={translate('coding_rules.description_context.title')}
- onCheck={this.handleToggleContext}
- options={options}
- value={selectedContext.displayName}
- />
- {selectedContext.key !== OTHERS_KEY && (
- <h2>
- {translateWithParameters(
- 'coding_rules.description_context.sub_title',
- selectedContext.displayName
- )}
- </h2>
- )}
- </div>
- {selectedContext.key === OTHERS_KEY ? (
- <OtherContextOption />
- ) : (
- <div
- /* eslint-disable-next-line react/no-danger */
- dangerouslySetInnerHTML={{ __html: sanitizeString(selectedContext.content) }}
- />
+ </h2>
)}
</div>
- </div>
+ {selectedContext.key === OTHERS_KEY ? (
+ <OtherContextOption />
+ ) : (
+ <div
+ /* eslint-disable-next-line react/no-danger */
+ dangerouslySetInnerHTML={{ __html: sanitizeString(selectedContext.content) }}
+ />
+ )}
+ </StyledHtmlFormatter>
);
}
return (
- <div
- className={classNames(className, {
- markdown: isDefault,
- 'rule-desc': !isDefault,
- })}
+ <StyledHtmlFormatter
+ className={className}
ref={(node) => {
applyCodeDifferences(node);
}}
);
}
}
+
+const StyledHtmlFormatter = styled(HtmlFormatter)`
+ .code-difference-container {
+ display: flex;
+ flex-direction: column;
+ width: fit-content;
+ min-width: 100%;
+ }
+
+ .code-difference-scrollable {
+ background-color: ${themeColor('codeSnippetBackground')};
+ border: ${themeBorder('default', 'codeSnippetBorder')};
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ overflow-x: auto;
+ }
+
+ .code-difference-scrollable .code-added,
+ .code-difference-scrollable .code-removed {
+ padding-left: 1.5rem;
+ margin-left: -1.5rem;
+ padding-right: 1.5rem;
+ margin-right: -1.5rem;
+ border-radius: 0;
+ }
+
+ .code-difference-scrollable .code-added {
+ background-color: ${themeColor('codeLineCoveredUnderline')};
+ }
+
+ .code-difference-scrollable .code-removed {
+ background-color: ${themeColor('codeLineUncoveredUnderline')};
+ }
+`;
descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] ||
descriptionSectionsByKey[RuleDescriptionSections.ROOT_CAUSE]
}
- isDefault={descriptionSectionsByKey[RuleDescriptionSections.DEFAULT] !== undefined}
defaultContextKey={ruleDescriptionContextKey}
/>
),