import { getJSON } from '~sonar-aligned/helpers/request';
import getCoverageStatus from '../components/SourceViewer/helpers/getCoverageStatus';
import { get, HttpStatus, parseJSON, post, postJSON, RequestData } from '../helpers/request';
-import { FacetName, IssueResponse, ListIssuesResponse, RawIssuesResponse } from '../types/issues';
+import {
+ FacetName,
+ IssueResponse,
+ IssueSeverity,
+ ListIssuesResponse,
+ RawIssuesResponse,
+} from '../types/issues';
import { Dict, FacetValue, IssueChangelog, SnippetsByComponent, SourceLine } from '../types/types';
export function searchIssues(query: RequestData): Promise<RawIssuesResponse> {
return postJSON('/api/issues/assign', data);
}
-export function setIssueSeverity(data: { issue: string; severity: string }): Promise<any> {
+export function setIssueSeverity(data: {
+ impacts?: string;
+ issue: string;
+ severity?: IssueSeverity;
+}): Promise<IssueResponse> {
return postJSON('/api/issues/set_severity', data);
}
return this.getActionsResponse({ type: data.type }, data.issue);
};
- handleSetIssueSeverity = (data: { issue: string; severity: string }) => {
- return this.getActionsResponse({ severity: data.severity }, data.issue);
+ handleSetIssueSeverity = (data: { impacts?: string; issue: string; severity?: string }) => {
+ const issueDataSelected = this.list.find((l) => l.issue.key === data.issue);
+
+ if (!issueDataSelected) {
+ throw new Error(`Coulnd't find issue for key ${data.issue}`);
+ }
+
+ const parsedImpact = data.impacts?.split('=');
+
+ return this.getActionsResponse(
+ data.impacts
+ ? {
+ impacts: issueDataSelected.issue.impacts.map((impact) =>
+ impact.softwareQuality === parsedImpact?.[0]
+ ? { ...impact, severity: parsedImpact?.[1] as SoftwareImpactSeverity }
+ : impact,
+ ),
+ }
+ : { severity: data.severity },
+ data.issue,
+ );
};
handleSetIssueAssignee = (data: { assignee?: string; issue: string }) => {
import { Helmet, HelmetProvider } from 'react-helmet-async';
import { IntlShape, RawIntlProvider } from 'react-intl';
import {
+ Outlet,
Route,
RouterProvider,
createBrowserRouter,
const router = createBrowserRouter(
createRoutesFromElements(
- <>
+ // Wrapper to pass toaster container under router context
+ // this way we can use router context in toast message, for example render links
+ <Route
+ element={
+ <>
+ <ToastMessageContainer />
+ <Outlet />
+ </>
+ }
+ >
{renderRedirects()}
<Route path="formatting/help" element={<FormattingHelp />} />
</Route>
</Route>
</Route>
- </>,
+ </Route>,
),
{ basename: getBaseUrl() },
);
<ThemeProvider theme={lightTheme}>
<QueryClientProvider client={queryClient}>
<GlobalStyles />
- <ToastMessageContainer />
<Helmet titleTemplate={translate('page_title.template.default')} />
<StackContext>
<EchoesProvider>
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import userEvent from '@testing-library/user-event';
import { byLabelText, byRole, byText } from '~sonar-aligned/helpers/testSelector';
+import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock';
import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
import { WorkspaceContext } from '../../../components/workspace/context';
-import { mockIssue, mockRuleDetails } from '../../../helpers/testMocks';
+import { mockIssue, mockRawIssue, mockRuleDetails } from '../../../helpers/testMocks';
import { renderComponent } from '../../../helpers/testReactTestingUtils';
+import { IssueActions, RawIssue } from '../../../types/issues';
import { SettingsKey } from '../../../types/settings';
import { Dict } from '../../../types/types';
import IssueHeader from '../components/IssueHeader';
+jest.mock('~design-system', () => ({
+ ...jest.requireActual('~design-system'),
+ addGlobalSuccessMessage: jest.fn(),
+}));
+
const settingsHandler = new SettingsServiceMock();
+const issuesHandler = new IssuesServiceMock();
beforeEach(() => {
settingsHandler.reset();
+ issuesHandler.reset();
});
it('renders correctly', async () => {
expect(byText('eslint').query()).not.toBeInTheDocument();
});
+it('can update the severity in MQR mode', async () => {
+ const user = userEvent.setup();
+ const onIssueChange = jest.fn();
+ const issue = mockIssue(false, { actions: [IssueActions.SetSeverity], prioritizedRule: false });
+ renderIssueHeader({
+ onIssueChange,
+ issue,
+ });
+
+ expect(await byText(`software_quality.MAINTAINABILITY`).find()).toBeInTheDocument();
+ await user.click(byText('software_quality.MAINTAINABILITY').get());
+ await user.click(byText('severity_impact.BLOCKER').get());
+ expect(onIssueChange).toHaveBeenCalledWith({
+ ...issue,
+ impacts: [{ softwareQuality: 'MAINTAINABILITY', severity: 'BLOCKER' }],
+ });
+});
+
+it('can update the severity in Standard mode', async () => {
+ settingsHandler.set(SettingsKey.MQRMode, 'false');
+ const user = userEvent.setup();
+ const onIssueChange = jest.fn();
+ const issue = mockIssue(false, { actions: [IssueActions.SetSeverity], prioritizedRule: false });
+ renderIssueHeader({
+ onIssueChange,
+ issue,
+ });
+
+ expect(await byLabelText(`severity.${issue.severity}`).find()).toBeInTheDocument();
+ await user.click(byLabelText(`severity.${issue.severity}`).get());
+ await user.click(byLabelText('severity.BLOCKER').get());
+
+ expect(onIssueChange).toHaveBeenCalledWith({
+ ...issue,
+ severity: 'BLOCKER',
+ });
+});
+
function renderIssueHeader(
props: Partial<IssueHeader['props']> = {},
externalRules: Dict<string> = {},
) {
+ issuesHandler.setIssueList([
+ { issue: mockRawIssue(false, props.issue as RawIssue), snippets: {} },
+ ]);
return renderComponent(
<WorkspaceContext.Provider
value={{ openComponent: jest.fn(), externalRulesRepoNames: externalRules }}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { IconLink } from '@sonarsource/echoes-react';
+import { IconLink, Link } from '@sonarsource/echoes-react';
import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
import {
+ addGlobalSuccessMessage,
Badge,
BasicSeparator,
ClipboardIconButton,
IssueMessageHighlighting,
- Link,
Note,
PageContentFontWrapper,
} from '~design-system';
import { getBranchLikeQuery } from '~sonar-aligned/helpers/branch-like';
import { getComponentIssuesUrl } from '~sonar-aligned/helpers/urls';
-import { setIssueAssignee } from '../../../api/issues';
+import { setIssueAssignee, setIssueSeverity } from '../../../api/issues';
import { updateIssue } from '../../../components/issue/actions';
import IssueActionsBar from '../../../components/issue/components/IssueActionsBar';
import { WorkspaceContext } from '../../../components/workspace/context';
import { getKeyboardShortcutEnabled } from '../../../helpers/preferences';
import { getPathUrlAsString, getRuleUrl } from '../../../helpers/urls';
import { BranchLike } from '../../../types/branch-like';
-import { IssueActions, IssueType } from '../../../types/issues';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy';
+import { IssueActions, IssueSeverity, IssueType } from '../../../types/issues';
import { Issue, RuleDetails } from '../../../types/types';
import IssueHeaderMeta from './IssueHeaderMeta';
import IssueHeaderSide from './IssueHeaderSide';
this.handleIssuePopupToggle('assign', false);
};
+ handleSeverityChange = (
+ severity: IssueSeverity | SoftwareImpactSeverity,
+ quality?: SoftwareQuality,
+ ) => {
+ const { issue } = this.props;
+
+ const data = quality
+ ? { issue: issue.key, impacts: `${quality}=${severity}` }
+ : { issue: issue.key, severity: severity as IssueSeverity };
+
+ const severityBefore = quality
+ ? issue.impacts.find((impact) => impact.softwareQuality === quality)?.severity
+ : issue.severity;
+
+ return updateIssue(
+ this.props.onIssueChange,
+ setIssueSeverity(data).then((r) => {
+ addGlobalSuccessMessage(
+ <FormattedMessage
+ id="issue.severity.updated_notification"
+ values={{
+ issueLink: undefined,
+ quality: quality ? translate('software_quality', quality) : undefined,
+ before: translate(quality ? 'severity_impact' : 'severity', severityBefore ?? ''),
+ after: translate(quality ? 'severity_impact' : 'severity', severity),
+ }}
+ />,
+ );
+ return r;
+ }),
+ );
+ };
+
handleKeyDown = (event: KeyboardEvent) => {
if (isInput(event) || isShortcut(event) || !getKeyboardShortcutEnabled()) {
return true;
showSonarLintBadge
/>
</div>
- <IssueHeaderSide issue={issue} />
+ <IssueHeaderSide
+ issue={issue}
+ onSetSeverity={
+ issue.actions.includes(IssueActions.SetSeverity) ? this.handleSeverityChange : undefined
+ }
+ />
<IssueNewStatusAndTransitionGuide
run
issues={[issue]}
import SoftwareImpactPillList from '../../../components/shared/SoftwareImpactPillList';
import { translate } from '../../../helpers/l10n';
import { useStandardExperienceMode } from '../../../queries/settings';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy';
import { IssueSeverity } from '../../../types/issues';
import { Issue } from '../../../types/types';
interface Props {
issue: Issue;
+ onSetSeverity?: ((severity: IssueSeverity) => Promise<void>) &
+ ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise<void>);
}
-export default function IssueHeaderSide({ issue }: Readonly<Props>) {
+export default function IssueHeaderSide({ issue, onSetSeverity }: Readonly<Props>) {
const { data: isStandardMode, isLoading } = useStandardExperienceMode();
return (
<StyledSection className="sw-flex sw-flex-col sw-pl-4 sw-max-w-[250px]">
title={isStandardMode ? translate('type') : translate('issue.software_qualities.label')}
>
<SoftwareImpactPillList
+ onSetSeverity={onSetSeverity}
className="sw-flex-wrap"
softwareImpacts={issue.impacts}
issueSeverity={issue.severity as IssueSeverity}
*/
import { flow } from 'lodash';
-import * as React from 'react';
-import { useCallback } from 'react';
+import { memo, useCallback, useEffect } from 'react';
import { setIssueAssignee } from '../../api/issues';
import { useComponent } from '../../app/components/componentContext/withComponentContext';
import { isInput, isShortcut } from '../../helpers/keyboardEventHelpers';
selected: boolean;
}
-export default function Issue(props: Props) {
+function Issue(props: Readonly<Props>) {
const {
selected = false,
issue,
[issue.actions, issue.key, togglePopup, handleAssignement, onCheck],
);
- React.useEffect(() => {
+ useEffect(() => {
if (selected) {
document.addEventListener('keydown', handleKeyDown, { capture: true });
}
/>
);
}
+
+export default memo(Issue);
await ui.addTag('android', ['accessibility']);
expect(ui.updateTagsBtn(['accessibility', 'android']).get()).toBeInTheDocument();
});
+
+ it('should allow updating the severity in MQR mode', async () => {
+ const { ui, user } = getPageObject();
+ const issue = mockRawIssue(false, {
+ impacts: [
+ { softwareQuality: SoftwareQuality.Maintainability, severity: SoftwareImpactSeverity.Low },
+ ],
+ actions: [IssueActions.SetSeverity],
+ });
+ issuesHandler.setIssueList([{ issue, snippets: {} }]);
+ renderIssue({
+ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }),
+ });
+ expect(ui.softwareQuality(SoftwareQuality.Maintainability).get()).toBeInTheDocument();
+
+ await user.click(ui.softwareQuality(SoftwareQuality.Maintainability).get());
+ await user.click(ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).get());
+ expect(ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).get()).toBeInTheDocument();
+ expect(byText(/issue.severity.updated_notification.link.mqr/).get()).toBeInTheDocument();
+ });
+
+ it('cannot update the severity in MQR mode without permission', async () => {
+ const { ui, user } = getPageObject();
+ const issue = mockRawIssue(false, {
+ impacts: [
+ { softwareQuality: SoftwareQuality.Maintainability, severity: SoftwareImpactSeverity.Low },
+ ],
+ });
+ issuesHandler.setIssueList([{ issue, snippets: {} }]);
+ renderIssue({
+ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }),
+ });
+ expect(ui.softwareQuality(SoftwareQuality.Maintainability).get()).toBeInTheDocument();
+ await user.click(ui.softwareQuality(SoftwareQuality.Maintainability).get());
+ expect(
+ ui.softwareQualitySeverity(SoftwareImpactSeverity.Medium).query(),
+ ).not.toBeInTheDocument();
+ // popover visible
+ expect(byRole('heading', { name: 'severity_impact.title' }).get()).toBeInTheDocument();
+ });
+
+ it('should allow updating the severity in Standard experience', async () => {
+ const { ui, user } = getPageObject();
+ const issue = mockRawIssue(false, {
+ actions: [IssueActions.SetSeverity],
+ });
+ settingsHandler.set(SettingsKey.MQRMode, 'false');
+ issuesHandler.setIssueList([{ issue, snippets: {} }]);
+ renderIssue({
+ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'severity') }),
+ });
+ expect(await ui.standardSeverity(IssueSeverity.Major).find()).toBeInTheDocument();
+
+ await user.click(ui.standardSeverity(IssueSeverity.Major).get());
+ await user.click(ui.standardSeverity(IssueSeverity.Info).get());
+ expect(ui.standardSeverity(IssueSeverity.Info).get()).toBeInTheDocument();
+ expect(byText(/issue.severity.updated_notification.link.standard/).get()).toBeInTheDocument();
+ });
+
+ it('cannot update the severity in Standard mode without permission', async () => {
+ const { ui, user } = getPageObject();
+ const issue = mockRawIssue(false);
+ issuesHandler.setIssueList([{ issue, snippets: {} }]);
+ settingsHandler.set(SettingsKey.MQRMode, 'false');
+ renderIssue({
+ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'impacts') }),
+ });
+ expect(await ui.standardSeverity(IssueSeverity.Major).find()).toBeInTheDocument();
+ await user.click(ui.standardSeverity(IssueSeverity.Major).get());
+ expect(ui.standardSeverity(IssueSeverity.Info).query()).not.toBeInTheDocument();
+ });
});
it('should correctly handle keyboard shortcuts', async () => {
commentDeleteBtn: byRole('button', { name: 'issue.comment.delete' }),
commentConfirmDeleteBtn: byRole('button', { name: 'delete' }),
- // Type
- updateTypeBtn: (currentType: IssueType) =>
- byLabelText(`issue.type.type_x_click_to_change.issue.type.${currentType}`),
- setTypeBtn: (type: IssueType) => byText(`issue.type.${type}`),
-
- // Severity
- updateSeverityBtn: (currentSeverity: IssueSeverity) =>
- byLabelText(`issue.severity.severity_x_click_to_change.severity.${currentSeverity}`),
- setSeverityBtn: (severity: IssueSeverity) => byText(`severity.${severity}`),
-
// Status
updateStatusBtn: (currentStatus: IssueStatus) =>
byLabelText(`issue.transition.status_x_click_to_change.issue.issue_status.${currentStatus}`),
await user.click(selectors.commentDeleteBtn.get());
await user.click(selectors.commentConfirmDeleteBtn.get());
},
- async updateType(currentType: IssueType, newType: IssueType) {
- await user.click(selectors.updateTypeBtn(currentType).get());
- await user.click(selectors.setTypeBtn(newType).get());
- },
- async updateSeverity(currentSeverity: IssueSeverity, newSeverity: IssueSeverity) {
- await user.click(selectors.updateSeverityBtn(currentSeverity).get());
- await user.click(selectors.setSeverityBtn(newSeverity).get());
- },
async updateStatus(currentStatus: IssueStatus, transition: IssueTransition) {
await user.click(selectors.updateStatusBtn(currentStatus).get());
await user.click(selectors.setStatusBtn(transition).get());
*/
import styled from '@emotion/styled';
-import { Checkbox } from '@sonarsource/echoes-react';
+import { Checkbox, Link, LinkHighlight } from '@sonarsource/echoes-react';
import classNames from 'classnames';
-import * as React from 'react';
-import { BasicSeparator, themeBorder } from '~design-system';
-import { deleteIssueComment, editIssueComment } from '../../../api/issues';
+import { useEffect, useRef } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { addGlobalSuccessMessage, BasicSeparator, themeBorder } from '~design-system';
+import { setIssueSeverity } from '../../../api/issues';
+import { useComponent } from '../../../app/components/componentContext/withComponentContext';
+import { areMyIssuesSelected, parseQuery, serializeQuery } from '../../../apps/issues/utils';
import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { getIssuesUrl } from '../../../helpers/urls';
+import { useLocation } from '../../../sonar-aligned/components/hoc/withRouter';
+import { getBranchLikeQuery } from '../../../sonar-aligned/helpers/branch-like';
+import { getComponentIssuesUrl } from '../../../sonar-aligned/helpers/urls';
import { BranchLike } from '../../../types/branch-like';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../../types/clean-code-taxonomy';
import { IssueActions, IssueSeverity } from '../../../types/issues';
import { Issue } from '../../../types/types';
import SoftwareImpactPillList from '../../shared/SoftwareImpactPillList';
togglePopup: (popup: string, show: boolean | void) => void;
}
-export default class IssueView extends React.PureComponent<Props> {
- nodeRef: HTMLLIElement | null = null;
-
- componentDidMount() {
- const { selected } = this.props;
- if (this.nodeRef && selected) {
- this.nodeRef.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
- }
- }
-
- componentDidUpdate(prevProps: Props) {
- const { selected } = this.props;
- if (!prevProps.selected && selected && this.nodeRef) {
- this.nodeRef.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
- }
- }
-
- handleCheck = () => {
- if (this.props.onCheck) {
- this.props.onCheck(this.props.issue.key);
+export default function IssueView(props: Readonly<Props>) {
+ const {
+ issue,
+ branchLike,
+ checked,
+ currentPopup,
+ displayWhyIsThisAnIssue,
+ onAssign,
+ onChange,
+ onSelect,
+ togglePopup,
+ selected,
+ onCheck,
+ } = props;
+ const intl = useIntl();
+ const nodeRef = useRef<HTMLLIElement>(null);
+ const { component } = useComponent();
+ const location = useLocation();
+ const query = parseQuery(location.query);
+
+ const hasCheckbox = onCheck != null;
+ const canSetTags = issue.actions.includes(IssueActions.SetTags);
+ const canSetSeverity = issue.actions.includes(IssueActions.SetSeverity);
+
+ const handleCheck = () => {
+ if (onCheck) {
+ onCheck(issue.key);
}
};
- editComment = (comment: string, text: string) => {
- updateIssue(this.props.onChange, editIssueComment({ comment, text }));
- };
-
- deleteComment = (comment: string) => {
- updateIssue(this.props.onChange, deleteIssueComment({ comment }));
+ const setSeverity = (
+ severity: IssueSeverity | SoftwareImpactSeverity,
+ quality?: SoftwareQuality,
+ ) => {
+ const { issue } = props;
+
+ const data = quality
+ ? { issue: issue.key, impacts: `${quality}=${severity}` }
+ : { issue: issue.key, severity: severity as IssueSeverity };
+
+ const severityBefore = quality
+ ? issue.impacts.find((impact) => impact.softwareQuality === quality)?.severity
+ : issue.severity;
+
+ const linkQuery = {
+ ...getBranchLikeQuery(branchLike),
+ ...serializeQuery(query),
+ myIssues: areMyIssuesSelected(location.query) ? 'true' : undefined,
+ open: issue.key,
+ };
+
+ return updateIssue(
+ onChange,
+ setIssueSeverity(data).then((r) => {
+ addGlobalSuccessMessage(
+ <FormattedMessage
+ id="issue.severity.updated_notification"
+ values={{
+ issueLink: (
+ <Link
+ highlight={LinkHighlight.Default}
+ to={
+ component
+ ? getComponentIssuesUrl(component.key, linkQuery)
+ : getIssuesUrl(linkQuery)
+ }
+ >
+ {intl.formatMessage(
+ {
+ id: `issue.severity.updated_notification.link.${!quality ? 'standard' : 'mqr'}`,
+ },
+ {
+ type: translate('issue.type', issue.type).toLowerCase(),
+ },
+ )}
+ </Link>
+ ),
+ quality: quality ? translate('software_quality', quality) : undefined,
+ before: translate(quality ? 'severity_impact' : 'severity', severityBefore ?? ''),
+ after: translate(quality ? 'severity_impact' : 'severity', severity),
+ }}
+ />,
+ );
+
+ return r;
+ }),
+ );
};
-
- render() {
- const { issue, branchLike, checked, currentPopup, displayWhyIsThisAnIssue } = this.props;
-
- const hasCheckbox = this.props.onCheck != null;
- const canSetTags = issue.actions.includes(IssueActions.SetTags);
-
- const issueClass = classNames('it__issue-item sw-p-3 sw-mb-4 sw-rounded-1 sw-bg-white', {
- selected: this.props.selected,
- });
-
- return (
- <IssueItem
- onClick={() => this.props.onSelect(issue.key)}
- className={issueClass}
- role="region"
- aria-label={issue.message}
- ref={(node) => (this.nodeRef = node)}
- >
- <div className="sw-flex sw-gap-3">
- {hasCheckbox && (
- <span className="sw-mt-1/2 sw-ml-1 sw-self-start">
- <Checkbox
- ariaLabel={translateWithParameters('issues.action_select.label', issue.message)}
- checked={checked ?? false}
- onCheck={this.handleCheck}
- title={translate('issues.action_select')}
- />
- </span>
- )}
-
- <div className="sw-flex sw-flex-col sw-grow sw-gap-3 sw-min-w-0">
- <IssueTitleBar
- branchLike={branchLike}
- displayWhyIsThisAnIssue={displayWhyIsThisAnIssue}
- issue={issue}
+ useEffect(() => {
+ if (selected && nodeRef.current) {
+ nodeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
+ }
+ }, [selected]);
+
+ return (
+ <IssueItem
+ onClick={() => onSelect(issue.key)}
+ className={classNames('it__issue-item sw-p-3 sw-mb-4 sw-rounded-1 sw-bg-white', {
+ selected,
+ })}
+ role="region"
+ aria-label={issue.message}
+ ref={nodeRef}
+ >
+ <div className="sw-flex sw-gap-3">
+ {hasCheckbox && (
+ <span className="sw-mt-1/2 sw-ml-1 sw-self-start">
+ <Checkbox
+ ariaLabel={translateWithParameters('issues.action_select.label', issue.message)}
+ checked={checked ?? false}
+ onCheck={handleCheck}
+ title={translate('issues.action_select')}
/>
-
- <div className="sw-mt-1 sw-flex sw-items-start sw-justify-between">
- <SoftwareImpactPillList
- data-guiding-id="issue-2"
- softwareImpacts={issue.impacts}
- issueSeverity={issue.severity as IssueSeverity}
- issueType={issue.type}
+ </span>
+ )}
+
+ <div className="sw-flex sw-flex-col sw-grow sw-gap-3 sw-min-w-0">
+ <IssueTitleBar
+ branchLike={branchLike}
+ displayWhyIsThisAnIssue={displayWhyIsThisAnIssue}
+ issue={issue}
+ />
+
+ <div className="sw-mt-1 sw-flex sw-items-start sw-justify-between">
+ <SoftwareImpactPillList
+ data-guiding-id="issue-2"
+ softwareImpacts={issue.impacts}
+ onSetSeverity={canSetSeverity ? setSeverity : undefined}
+ issueSeverity={issue.severity as IssueSeverity}
+ issueType={issue.type}
+ />
+ <div className="sw-grow-0 sw-whitespace-nowrap">
+ <IssueTags
+ issue={issue}
+ onChange={onChange}
+ togglePopup={togglePopup}
+ canSetTags={canSetTags}
+ open={currentPopup === 'edit-tags' && canSetTags}
/>
- <div className="sw-grow-0 sw-whitespace-nowrap">
- <IssueTags
- issue={issue}
- onChange={this.props.onChange}
- togglePopup={this.props.togglePopup}
- canSetTags={canSetTags}
- open={currentPopup === 'edit-tags' && canSetTags}
- />
- </div>
</div>
+ </div>
- <BasicSeparator />
+ <BasicSeparator />
- <div className="sw-flex sw-gap-2 sw-flex-nowrap sw-items-center sw-justify-between">
- <IssueActionsBar
- currentPopup={currentPopup}
- issue={issue}
- onAssign={this.props.onAssign}
- onChange={this.props.onChange}
- togglePopup={this.props.togglePopup}
- />
- <IssueMetaBar issue={issue} />
- </div>
+ <div className="sw-flex sw-gap-2 sw-flex-nowrap sw-items-center sw-justify-between">
+ <IssueActionsBar
+ currentPopup={currentPopup}
+ issue={issue}
+ onAssign={onAssign}
+ onChange={onChange}
+ togglePopup={togglePopup}
+ />
+ <IssueMetaBar issue={issue} />
</div>
</div>
- </IssueItem>
- );
- }
+ </div>
+ </IssueItem>
+ );
}
const IssueItem = styled.li`
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { Tooltip } from '@sonarsource/echoes-react';
+import { DropdownMenu, DropdownMenuAlign, Spinner, Tooltip } from '@sonarsource/echoes-react';
import classNames from 'classnames';
+import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Pill, PillVariant } from '~design-system';
import { IssueSeverity, IssueType } from '../../types/issues';
export interface Props {
className?: string;
issueType: string;
+ onSetSeverity?: (severity: IssueSeverity) => Promise<void>;
severity: IssueSeverity;
}
export default function IssueTypePill(props: Readonly<Props>) {
- const { className, severity, issueType } = props;
+ const { className, severity, issueType, onSetSeverity } = props;
const intl = useIntl();
-
+ const [updatingSeverity, setUpdatingSeverity] = useState(false);
const variant = {
[IssueSeverity.Blocker]: PillVariant.Critical,
[IssueSeverity.Critical]: PillVariant.Danger,
[IssueSeverity.Info]: PillVariant.Info,
}[severity];
+ const renderPill = (notClickable = false) => (
+ <Pill
+ notClickable={notClickable}
+ className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
+ variant={issueType !== IssueType.SecurityHotspot ? variant : PillVariant.Accent}
+ >
+ <IssueTypeIcon type={issueType} />
+ {intl.formatMessage({ id: `issue.type.${issueType}` })}
+ <Spinner isLoading={updatingSeverity} className="sw-ml-1/2">
+ {issueType !== IssueType.SecurityHotspot && (
+ <SoftwareImpactSeverityIcon
+ width={14}
+ height={14}
+ severity={severity}
+ data-guiding-id="issue-3"
+ />
+ )}
+ </Spinner>
+ </Pill>
+ );
+
+ const handleSetSeverity = async (severity: IssueSeverity) => {
+ setUpdatingSeverity(true);
+ await onSetSeverity?.(severity);
+ setUpdatingSeverity(false);
+ };
+
+ if (onSetSeverity) {
+ return (
+ <DropdownMenu.Root
+ align={DropdownMenuAlign.Start}
+ items={Object.values(IssueSeverity).map((severityItem) => (
+ <DropdownMenu.ItemButtonCheckable
+ key={severityItem}
+ isDisabled={severityItem === severity}
+ isChecked={severityItem === severity}
+ onClick={() => handleSetSeverity(severityItem)}
+ >
+ <div className="sw-flex sw-items-center sw-gap-2">
+ <SoftwareImpactSeverityIcon width={14} height={14} severity={severityItem} />
+ {intl.formatMessage({ id: `severity.${severityItem}` })}
+ </div>
+ </DropdownMenu.ItemButtonCheckable>
+ ))}
+ >
+ <Tooltip
+ content={intl.formatMessage(
+ {
+ id: `issue.type.tooltip_with_change`,
+ },
+ {
+ severity: intl.formatMessage({ id: `severity.${severity}` }),
+ },
+ )}
+ >
+ {renderPill()}
+ </Tooltip>
+ </DropdownMenu.Root>
+ );
+ }
+
return (
<Tooltip
content={
},
{
severity: intl.formatMessage({ id: `severity.${severity}` }),
- type: (
- <span className="sw-lowercase">
- {intl.formatMessage({ id: `issue.type.${issueType}` })}
- </span>
- ),
},
)
}
>
- <Pill
- notClickable
- className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
- variant={issueType !== IssueType.SecurityHotspot ? variant : PillVariant.Accent}
- >
- <IssueTypeIcon type={issueType} />
- {intl.formatMessage({ id: `issue.type.${issueType}` })}
- {issueType !== IssueType.SecurityHotspot && (
- <SoftwareImpactSeverityIcon
- width={14}
- height={14}
- severity={severity}
- data-guiding-id="issue-3"
- />
- )}
- </Pill>
+ {renderPill(true)}
</Tooltip>
);
}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { Popover } from '@sonarsource/echoes-react';
+import {
+ DropdownMenu,
+ DropdownMenuAlign,
+ Popover,
+ Spinner,
+ Tooltip,
+} from '@sonarsource/echoes-react';
import classNames from 'classnames';
import { noop } from 'lodash';
-import { FormattedMessage } from 'react-intl';
+import { useState } from 'react';
+import { FormattedMessage, useIntl } from 'react-intl';
import { Pill, PillVariant } from '~design-system';
+import { IMPACT_SEVERITIES } from '../../helpers/constants';
import { DocLink } from '../../helpers/doc-links';
import { translate } from '../../helpers/l10n';
-import { SoftwareImpactSeverity } from '../../types/clean-code-taxonomy';
+import { SoftwareImpactSeverity, SoftwareQuality } from '../../types/clean-code-taxonomy';
import DocumentationLink from '../common/DocumentationLink';
import SoftwareImpactSeverityIcon from '../icon-mappers/SoftwareImpactSeverityIcon';
export interface Props {
className?: string;
- quality: string;
+ onSetSeverity?: (severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise<void>;
severity: SoftwareImpactSeverity;
+ softwareQuality: SoftwareQuality;
type?: 'issue' | 'rule';
}
export default function SoftwareImpactPill(props: Props) {
- const { className, severity, quality, type = 'issue' } = props;
+ const { className, severity, softwareQuality, type = 'issue', onSetSeverity } = props;
+ const intl = useIntl();
+ const quality = getQualityLabel(softwareQuality);
+ const [updatingSeverity, setUpdatingSeverity] = useState(false);
const variant = {
[SoftwareImpactSeverity.Blocker]: PillVariant.Critical,
[SoftwareImpactSeverity.Info]: PillVariant.Info,
}[severity];
+ const pill = (
+ <Pill
+ className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
+ onClick={noop}
+ variant={variant}
+ >
+ {quality}
+ <Spinner isLoading={updatingSeverity} className="sw-ml-1/2">
+ <SoftwareImpactSeverityIcon
+ width={14}
+ height={14}
+ severity={severity}
+ data-guiding-id="issue-3"
+ />
+ </Spinner>
+ </Pill>
+ );
+
+ const handleSetSeverity = async (severity: SoftwareImpactSeverity, quality: SoftwareQuality) => {
+ setUpdatingSeverity(true);
+ await onSetSeverity?.(severity, quality);
+ setUpdatingSeverity(false);
+ };
+
+ if (onSetSeverity && type === 'issue') {
+ return (
+ <DropdownMenu.Root
+ align={DropdownMenuAlign.Start}
+ items={IMPACT_SEVERITIES.map((impactSeverity) => (
+ <DropdownMenu.ItemButtonCheckable
+ key={impactSeverity}
+ isDisabled={impactSeverity === severity}
+ isChecked={impactSeverity === severity}
+ onClick={() => handleSetSeverity(impactSeverity, softwareQuality)}
+ >
+ <div className="sw-flex sw-items-center sw-gap-2">
+ <SoftwareImpactSeverityIcon width={14} height={14} severity={impactSeverity} />
+ {translate('severity_impact', impactSeverity)}
+ </div>
+ </DropdownMenu.ItemButtonCheckable>
+ ))}
+ >
+ <Tooltip
+ content={intl.formatMessage(
+ {
+ id: `issue.type.tooltip_with_change`,
+ },
+ {
+ severity: intl.formatMessage({ id: `severity_impact.${severity}` }),
+ },
+ )}
+ >
+ {pill}
+ </Tooltip>
+ </DropdownMenu.Root>
+ );
+ }
+
return (
<Popover
title={translate('severity_impact.title')}
/>
<p className="sw-mt-2">
<span className="sw-mr-1">{translate('severity_impact.help.line1')}</span>
- {translate('severity_impact.help.line2')}
+ {type === 'issue' && translate('severity_impact.help.line2')}
</p>
</>
}
</DocumentationLink>
}
>
- <Pill
- className={classNames('sw-flex sw-gap-1 sw-items-center', className)}
- onClick={noop}
- variant={variant}
+ <Tooltip
+ content={intl.formatMessage(
+ {
+ id: `issue.type.tooltip`,
+ },
+ {
+ severity: intl.formatMessage({ id: `severity_impact.${severity}` }),
+ },
+ )}
>
- {quality}
- <SoftwareImpactSeverityIcon
- width={14}
- height={14}
- severity={severity}
- data-guiding-id="issue-3"
- />
- </Pill>
+ {pill}
+ </Tooltip>
</Popover>
);
}
+
+const getQualityLabel = (quality: SoftwareQuality) => translate('software_quality', quality);
className?: string;
issueSeverity?: IssueSeverity;
issueType?: string;
+ onSetSeverity?: ((severity: IssueSeverity) => Promise<void>) &
+ ((severity: SoftwareImpactSeverity, quality: SoftwareQuality) => Promise<void>);
softwareImpacts: SoftwareImpact[];
type?: Parameters<typeof SoftwareImpactPill>[0]['type'];
}
export default function SoftwareImpactPillList({
softwareImpacts,
+ onSetSeverity,
issueSeverity,
issueType,
type,
.map(({ severity, softwareQuality }) => (
<li key={softwareQuality}>
<SoftwareImpactPill
+ onSetSeverity={onSetSeverity}
severity={severity}
- quality={getQualityLabel(softwareQuality)}
+ softwareQuality={softwareQuality}
type={type}
/>
</li>
<IssueTypePill severity={issueSeverity ?? IssueSeverity.Info} issueType={issueType} />
)}
{isStandardMode && issueType && issueSeverity && (
- <IssueTypePill severity={issueSeverity} issueType={issueType} />
+ <IssueTypePill
+ onSetSeverity={onSetSeverity}
+ severity={issueSeverity}
+ issueType={issueType}
+ />
)}
</ul>
);
const router = createMemoryRouter(
createRoutesFromElements(
- <>
+ <Route
+ element={
+ <>
+ <Outlet />
+ <ToastMessageContainer />
+ </>
+ }
+ >
{children}
<Route path="*" element={<CatchAll />} />
- </>,
+ </Route>,
),
{ initialEntries: [path] },
);
<AppStateContextProvider appState={appState}>
<IndexationContextProvider>
<QueryClientProvider client={queryClient}>
- <ToastMessageContainer />
<EchoesProvider tooltipsDelayDuration={0}>
<RouterProvider router={router} />
</EchoesProvider>
issue.send_notifications=Send Notifications
issue.why_this_issue=Why is this an issue?
issue.why_this_issue.long=Why is this an issue? Open the rule's details at the bottom of the page.
-issue.type.type_x_click_to_change=Type: {0}, click to change
-issue.severity.severity_x_click_to_change=Severity: {0}, click to change
+issue.severity.updated_notification.link.standard=This {type}'s
+issue.severity.updated_notification.link.mqr=This issues's
+issue.severity.updated_notification={issueLink, select, other { {issueLink} }} {quality, select, other { {quality} }} severity was changed from {before} to {after}
issue.change_status=Change Status
issue.transition.community_plug_link=SonarSource Community
issue.transition.status_x_click_to_change=Issue status: {0}, click to change
issue.type.CODE_SMELL.plural=Code Smells
issue.type.BUG.plural=Bugs
issue.type.VULNERABILITY.plural=Vulnerabilities
-issue.type.tooltip={severity} severity {type}
-
+issue.type.tooltip={severity} severity.
+issue.type.tooltip_with_change={severity} severity. Click to change.
issue.type.deprecation.title=Issue types are deprecated and can no longer be modified.
issue.type.deprecation.filter_by=You can now filter issues by:
issue.type.deprecation.documentation=Documentation
severity_impact.INFO=Info
severity_impact.INFO.description=Neither a bug nor a quality flaw. Just a finding.
severity_impact.help.line1=Severities are now directly tied to the software quality impacted. This means that one software quality impacted has one severity.
-severity_impact.help.line2=There are three levels of severity: high, medium, and low.
-
+severity_impact.help.line2=They can be changed with sufficient permissions.
#------------------------------------------------------------------------------
#
guiding.issue_list.2.content.2=You can now filter by these qualities to evaluate the areas in your software that are impacted by the introduction of code that isn't clean.
guiding.issue_list.3.title=Severity and Software Qualities
guiding.issue_list.3.content.1=Severities are now directly tied to the software quality impacted. This means that one software quality impacted has one severity.
-guiding.issue_list.3.content.2=There are only 3 levels: high, medium, and low.
+guiding.issue_list.3.content.2=There are five levels: blocker, high, medium, low and info.
guiding.issue_list.4.title=Type and old severity deprecated
guiding.issue_list.4.content.1=Issue types and the old severities are deprecated and can no longer be modified.
guiding.issue_list.4.content.2=You can now filter issues by: