--- /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 { ReactNode } from 'react';
+import tw from 'twin.macro';
+import { Highlight, Note } from './Text';
+import { RequiredIcon } from './icons';
+
+interface Props {
+ ariaLabel?: string;
+ children: ReactNode;
+ className?: string;
+ description?: string | ReactNode;
+ help?: ReactNode;
+ htmlFor?: string;
+ id?: string;
+ label: string | ReactNode;
+ required?: boolean;
+ title?: string;
+}
+
+export function FormField({
+ children,
+ className,
+ description,
+ help,
+ id,
+ required,
+ label,
+ htmlFor,
+ title,
+ ariaLabel,
+}: Props) {
+ return (
+ <FieldWrapper className={className} id={id}>
+ <label aria-label={ariaLabel} className="sw-mb-2" htmlFor={htmlFor} title={title}>
+ <Highlight className="sw-flex sw-items-center sw-gap-2">
+ {label}
+ {required && <RequiredIcon className="sw--ml-1" />}
+ {help}
+ </Highlight>
+ </label>
+
+ {children}
+
+ {description && <Note className="sw-mt-2">{description}</Note>}
+ </FieldWrapper>
+ );
+}
+
+const FieldWrapper = styled.div`
+ ${tw`sw-flex sw-flex-col sw-w-full`}
+
+ &:not(:last-of-type) {
+ ${tw`sw-mb-6`}
+ }
+`;
Group extends GroupBase<Option> = GroupBase<Option>
> extends SelectProps<V, Option, IsMulti, Group>,
AsyncProps<Option, IsMulti, Group> {
+ controlAriaLabel?: string;
controlLabel?: React.ReactNode | string;
isDiscreet?: boolean;
}
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
>(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
- const { isDiscreet, value, loadOptions, controlLabel, isDisabled, minLength, ...rest } = props;
+ const {
+ isDiscreet,
+ value,
+ loadOptions,
+ controlLabel,
+ isDisabled,
+ minLength,
+ controlAriaLabel,
+ ...rest
+ } = props;
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
<DropdownToggler
allowResizing={true}
className="sw-overflow-visible sw-border-none"
+ isPortal={true}
onRequestClose={() => {
toggleDropdown(false);
}}
}
>
<SearchSelectDropdownControl
+ ariaLabel={controlAriaLabel}
disabled={isDisabled}
isDiscreet={isDiscreet}
label={controlLabel}
import { ChevronDownIcon } from './icons';
interface SearchSelectDropdownControlProps {
+ ariaLabel?: string;
disabled?: boolean;
isDiscreet?: boolean;
label?: React.ReactNode | string;
}
export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
- const { disabled, label, isDiscreet, onClick, size = 'full' } = props;
+ const { disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
return (
<StyledControl
+ aria-label={ariaLabel}
className={classNames({ 'is-discreet': isDiscreet })}
onClick={() => {
if (!disabled) {
export const PageContentFontWrapper = styled.div`
color: ${themeColor('pageContent')};
`;
+
+export const Highlight = styled.strong`
+ color: ${themeColor('pageContentDark')};
+
+ ${tw`sw-body-sm-highlight`}
+`;
+
+export const Note = styled.span`
+ color: ${themeColor('pageContentLight')};
+
+ ${tw`sw-body-sm`}
+`;
--- /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 { screen } from '@testing-library/react';
+import { FCProps } from '~types/misc';
+import { render } from '../../helpers/testUtils';
+import { FormField } from '../FormField';
+
+it('should render correctly', () => {
+ renderFormField({}, <input id="input" />);
+ expect(screen.getByLabelText('Hello')).toBeInTheDocument();
+});
+
+it('should render with required and description', () => {
+ renderFormField({ description: 'some description', required: true }, <input id="input" />);
+ expect(screen.getByText('some description')).toBeInTheDocument();
+ expect(screen.getByText('*')).toBeInTheDocument();
+});
+
+function renderFormField(
+ props: Partial<FCProps<typeof FormField>> = {},
+ children: any = <div>Fake input</div>
+) {
+ return render(
+ <FormField htmlFor="input" label="Hello" {...props}>
+ {children}
+ </FormField>
+ );
+}
disabled?: boolean;
icon?: React.ReactNode;
innerRef?: React.Ref<HTMLButtonElement>;
- onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;
+ onClick?: (event?: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => unknown;
preventDefault?: boolean;
reloadDocument?: LinkProps['reloadDocument'];
--- /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 { themeColor } from '../../helpers/theme';
+
+export function RequiredIcon(props: React.ComponentPropsWithoutRef<'em'>) {
+ return <StyledEm {...props}>*</StyledEm>;
+}
+
+export const StyledEm = styled.em`
+ ${tw`sw-body-sm`}
+ ${tw`sw-not-italic`}
+ ${tw`sw-ml-2`}
+ color: ${themeColor('inputRequired')};
+`;
export { ProjectIcon } from './ProjectIcon';
export { PullRequestIcon } from './PullRequestIcon';
export { RefreshIcon } from './RefreshIcon';
+export { RequiredIcon } from './RequiredIcon';
export { SecurityHotspotIcon } from './SecurityHotspotIcon';
export { SeparatorCircleIcon } from './SeparatorCircleIcon';
export { SeverityBlockerIcon } from './SeverityBlockerIcon';
export { FailedQGConditionLink } from './FailedQGConditionLink';
export { FlagMessage } from './FlagMessage';
export * from './FlowStep';
+export * from './FormField';
export * from './GenericAvatar';
export * from './HighlightedSection';
export { HotspotRating } from './HotspotRating';
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { withTheme } from '@emotion/react';
+import { useTheme, withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
LargeCenteredLayout,
onShowAllHotspots,
} = props;
+ const theme = useTheme();
+
return (
<>
<Suggestions suggestions="security_hotspots" />
)}
</StyledFilterbar>
- <main className="sw-col-span-8">
- {hotspots.length === 0 || !selectedHotspot ? (
- <EmptyHotspotsPage
- filtered={
- filters.assignedToMe ||
- (isBranch(branchLike) && filters.inNewCodePeriod) ||
- filters.status !== HotspotStatusFilter.TO_REVIEW
- }
- filterByFile={Boolean(filterByFile)}
- isStaticListOfHotspots={isStaticListOfHotspots}
- />
- ) : (
- <HotspotViewer
- component={component}
- hotspotKey={selectedHotspot.key}
- hotspotsReviewedMeasure={hotspotsReviewedMeasure}
- onSwitchStatusFilter={props.onSwitchStatusFilter}
- onUpdateHotspot={props.onUpdateHotspot}
- onLocationClick={props.onLocationClick}
- selectedHotspotLocation={selectedHotspotLocation}
- />
- )}
+ <main className="sw-col-span-8 sw-pl-12">
+ <StyledContentWrapper theme={theme} className="sw-h-full">
+ {hotspots.length === 0 || !selectedHotspot ? (
+ <EmptyHotspotsPage
+ filtered={
+ filters.assignedToMe ||
+ (isBranch(branchLike) && filters.inNewCodePeriod) ||
+ filters.status !== HotspotStatusFilter.TO_REVIEW
+ }
+ filterByFile={Boolean(filterByFile)}
+ isStaticListOfHotspots={isStaticListOfHotspots}
+ />
+ ) : (
+ <HotspotViewer
+ component={component}
+ hotspotKey={selectedHotspot.key}
+ onSwitchStatusFilter={props.onSwitchStatusFilter}
+ onUpdateHotspot={props.onUpdateHotspot}
+ onLocationClick={props.onLocationClick}
+ selectedHotspotLocation={selectedHotspotLocation}
+ standards={standards}
+ />
+ )}
+ </StyledContentWrapper>
</main>
</div>
</PageContentFontWrapper>
height: calc(100vh - ${'100px'});
`
);
+
+const StyledContentWrapper = withTheme(
+ styled.div`
+ background-color: ${themeColor('backgroundSecondary')};
+ border-right: ${themeBorder('default', 'pageBlockBorder')};
+ border-left: ${themeBorder('default', 'pageBlockBorder')};
+ `
+);
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { act, screen, within } from '@testing-library/react';
+import { act, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Route } from 'react-router-dom';
jest.mock('../../../api/issues');
const ui = {
- inputAssignee: byRole('searchbox', { name: 'hotspots.assignee.select_user' }),
- selectStatusButton: byRole('button', {
- name: 'hotspots.status.select_status',
- }),
- editAssigneeButton: byRole('button', {
- name: 'hotspots.assignee.change_user',
- }),
+ inputAssignee: byRole('combobox', { name: 'search.search_for_users' }),
filterAssigneeToMe: byRole('checkbox', {
name: 'hotspot.filters.assignee.assigned_to_me',
}),
filterByPeriod: byRole('combobox', { name: 'hotspot.filters.period' }),
filterNewCode: byRole('checkbox', { name: 'hotspot.filters.period.since_leak_period' }),
noHotspotForFilter: byText('hotspots.no_hotspots_for_filters.title'),
- selectStatus: byRole('button', { name: 'hotspots.status.select_status' }),
+ reviewButton: byRole('button', { name: 'hotspots.status.review' }),
toReviewStatus: byText('hotspots.status_option.TO_REVIEW'),
changeStatus: byRole('button', { name: 'hotspots.status.change_status' }),
hotspotTitle: (name: string | RegExp) => byRole('heading', { name }),
commentEditButton: byRole('button', { name: 'issue.comment.edit' }),
commentDeleteButton: byRole('button', { name: 'issue.comment.delete' }),
textboxWithText: (value: string) => byDisplayValue(value),
- activeAssignee: byTestId('assignee-name'),
+ activeAssignee: byRole('combobox', { name: 'hotspots.assignee.change_user' }),
successGlobalMessage: byTestId('global-message__SUCCESS'),
currentUserSelectionItem: byText('foo'),
panel: byTestId('security-hotspot-test'),
expect(await ui.activeAssignee.find()).toHaveTextContent('John Doe');
- await user.click(ui.editAssigneeButton.get());
+ await user.click(ui.activeAssignee.get());
await user.click(ui.currentUserSelectionItem.get());
expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.foo`);
const user = userEvent.setup();
renderSecurityHotspotsApp();
- await user.click(await ui.editAssigneeButton.find());
+ await user.click(await ui.activeAssignee.find());
await user.click(ui.inputAssignee.get());
await user.keyboard('User');
expect(searchUsers).toHaveBeenLastCalledWith({ q: 'User' });
- await user.keyboard('{ArrowDown}{Enter}');
+ await user.keyboard('{Enter}');
expect(ui.successGlobalMessage.get()).toHaveTextContent(`hotspots.assign.success.User John`);
});
renderSecurityHotspotsApp();
- expect(await ui.selectStatus.find()).toBeInTheDocument();
+ expect(await ui.reviewButton.find()).toBeInTheDocument();
- await user.click(ui.selectStatus.get());
+ await user.click(ui.reviewButton.get());
await user.click(ui.toReviewStatus.get());
await user.click(screen.getByRole('textbox', { name: 'hotspots.status.add_comment' }));
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();
- });
-
- it('should remember the comment when toggling change status panel for the same security hotspot', async () => {
- const user = userEvent.setup();
- renderSecurityHotspotsApp();
-
- await user.click(await ui.selectStatusButton.find());
- const comment = 'This is a comment';
-
- const commentSection = within(ui.panel.get()).getByRole('textbox');
- await user.click(commentSection);
- await user.keyboard(comment);
-
- // Close the panel
- await act(async () => {
- await user.keyboard('{Escape}');
- });
-
- // Check panel is closed
- expect(ui.panel.query()).not.toBeInTheDocument();
-
- await user.click(ui.selectStatusButton.get());
-
- expect(await screen.findByText(comment)).toBeInTheDocument();
+ expect(await ui.reviewButton.find()).toBeDisabled();
});
it('should be able to add, edit and remove own comments', async () => {
--- /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 { Avatar, LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
+import { noop } from 'lodash';
+import * as React from 'react';
+import { Options, SingleValue } from 'react-select';
+import { assignSecurityHotspot } from '../../../api/security-hotspots';
+import { searchUsers } from '../../../api/users';
+import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
+import { addGlobalSuccessMessage } from '../../../helpers/globalMessages';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+import { Hotspot, HotspotResolution, HotspotStatus } from '../../../types/security-hotspots';
+import { isLoggedIn, isUserActive } from '../../../types/users';
+
+interface Props {
+ hotspot: Hotspot;
+ onAssigneeChange: () => Promise<void>;
+}
+
+const minSearchLength = 2;
+
+const UNASSIGNED = { value: '', label: translate('unassigned') };
+
+const renderAvatar = (name?: string, avatar?: string) => (
+ <Avatar hash={avatar} name={name} size="xs" />
+);
+
+export default function Assignee(props: Props) {
+ const {
+ hotspot: { assigneeUser, status, resolution, key },
+ } = props;
+ const { currentUser } = React.useContext(CurrentUserContext);
+
+ const allowCurrentUserSelection =
+ isLoggedIn(currentUser) && currentUser?.login !== assigneeUser?.login;
+
+ const defaultOptions = allowCurrentUserSelection
+ ? [
+ UNASSIGNED,
+ {
+ value: currentUser.login,
+ label: currentUser.name,
+ Icon: renderAvatar(currentUser.name, currentUser.avatar),
+ },
+ ]
+ : [UNASSIGNED];
+
+ const canEdit =
+ status === HotspotStatus.TO_REVIEW || resolution === HotspotResolution.ACKNOWLEDGED;
+
+ const controlLabel = assigneeUser ? (
+ <>
+ {renderAvatar(assigneeUser?.name, assigneeUser.avatar)} {assigneeUser.name}
+ </>
+ ) : (
+ UNASSIGNED.label
+ );
+
+ const handleSearchAssignees = (
+ query: string,
+ cb: (options: Options<LabelValueSelectOption<string>>) => void
+ ) => {
+ searchUsers({ q: query })
+ .then((result) => {
+ const options: Array<LabelValueSelectOption<string>> = result.users
+ .filter(isUserActive)
+ .map((u) => ({
+ label: u.name ?? u.login,
+ value: u.login,
+ Icon: renderAvatar(u.name, u.avatar),
+ }));
+ cb(options);
+ })
+ .catch(() => {
+ cb([]);
+ });
+ };
+
+ const handleAssign = (userOption: SingleValue<LabelValueSelectOption<string>>) => {
+ if (userOption) {
+ assignSecurityHotspot(key, {
+ assignee: userOption.value,
+ })
+ .then(() => {
+ props.onAssigneeChange();
+ addGlobalSuccessMessage(
+ userOption.value
+ ? translateWithParameters('hotspots.assign.success', userOption.label)
+ : translate('hotspots.assign.unassign.success')
+ );
+ })
+ .catch(noop);
+ }
+ };
+
+ return (
+ <SearchSelectDropdown
+ size="medium"
+ isDisabled={!canEdit || !isLoggedIn(currentUser)}
+ controlAriaLabel={translate('hotspots.assignee.change_user')}
+ defaultOptions={defaultOptions}
+ onChange={handleAssign}
+ loadOptions={handleSearchAssignees}
+ minLength={minSearchLength}
+ isDiscreet={true}
+ controlLabel={controlLabel}
+ tooShortText={translateWithParameters('search.tooShort', String(minSearchLength))}
+ placeholder={translate('search.search_for_users')}
+ aria-label={translate('search.search_for_users')}
+ />
+ );
+}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { Note } from 'design-system';
import * as React from 'react';
import DocLink from '../../../components/common/DocLink';
import { translate } from '../../../helpers/l10n';
}
return (
- <div className="display-flex-column display-flex-center huge-spacer-top">
+ <div className="sw-items-center sw-justify-center sw-flex-col sw-flex sw-pt-16">
<img
alt={translate('hotspots.page')}
- className="huge-spacer-top"
+ className="sw-mt-8"
height={100}
src={`${getBaseUrl()}/images/${
filtered && !filterByFile ? 'filter-large' : 'hotspot-large'
}.svg`}
/>
- <h1 className="huge-spacer-top">{translate(`hotspots.${translationRoot}.title`)}</h1>
- <div className="abs-width-400 text-center big-spacer-top">
+ <h1 className="sw-mt-10 sw-body-sm-highlight">
+ {translate(`hotspots.${translationRoot}.title`)}
+ </h1>
+ <Note className="sw-w-abs-400 sw-text-center sw-mt-4">
{translate(`hotspots.${translationRoot}.description`)}
- </div>
+ </Note>
{!(filtered || isStaticListOfHotspots) && (
<DocLink className="big-spacer-top" to="/user-guide/security-hotspots/">
{translate('hotspots.learn_more')}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { withTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import {
+ ClipboardIconButton,
+ LAYOUT_GLOBAL_NAV_HEIGHT,
+ LAYOUT_PROJECT_NAV_HEIGHT,
+ LightLabel,
+ LightPrimary,
+ Link,
+ LinkIcon,
+ StyledPageTitle,
+ themeColor,
+} from 'design-system';
import React from 'react';
-import Link from '../../../components/common/Link';
-import Tooltip from '../../../components/controls/Tooltip';
import { IssueMessageHighlighting } from '../../../components/issue/IssueMessageHighlighting';
-import { translate } from '../../../helpers/l10n';
-import { getRuleUrl } from '../../../helpers/urls';
+import { getBranchLikeQuery } from '../../../helpers/branch-like';
+import {
+ getComponentSecurityHotspotsUrl,
+ getPathUrlAsString,
+ getRuleUrl,
+} from '../../../helpers/urls';
+import { BranchLike } from '../../../types/branch-like';
+import { SecurityStandard, Standards } from '../../../types/security';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
-import Assignee from './assignee/Assignee';
+import { Component } from '../../../types/types';
+import HotspotHeaderRightSection from './HotspotHeaderRightSection';
import Status from './status/Status';
export interface HotspotHeaderProps {
hotspot: Hotspot;
+ component: Component;
+ branchLike?: BranchLike;
+ standards?: Standards;
onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
}
export function HotspotHeader(props: HotspotHeaderProps) {
- const { hotspot } = props;
- const { message, messageFormattings, rule } = hotspot;
+ const { hotspot, component, branchLike, standards } = props;
+ const { message, messageFormattings, rule, key } = hotspot;
+
+ const permalink = getPathUrlAsString(
+ getComponentSecurityHotspotsUrl(component.key, {
+ ...getBranchLikeQuery(branchLike),
+ hotspots: key,
+ }),
+ false
+ );
+
+ const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title;
+
return (
- <div className="huge-spacer-bottom hotspot-header">
- <div className="display-flex-column big-spacer-bottom">
- <h2 className="big text-bold">
- <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
- </h2>
- <div className="spacer-top">
- <span className="note padded-right">{rule.name}</span>
- <Link className="small" to={getRuleUrl(rule.key)} target="_blank">
- {rule.key}
- </Link>
- </div>
- </div>
- <div className="display-flex-space-between">
- <Status
- hotspot={hotspot}
- onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)}
- />
- <div className="display-flex-end display-flex-column abs-width-240">
- {hotspot.codeVariants && hotspot.codeVariants.length > 0 && (
- <Tooltip overlay={hotspot.codeVariants.join(', ')}>
- <div className="spacer-bottom display-flex-center">
- <div>{translate('issues.facet.codeVariants')}:</div>
- <div className="text-bold spacer-left spacer-right text-ellipsis">
- {hotspot.codeVariants.join(', ')}
- </div>
- </div>
- </Tooltip>
- )}
- <div className="display-flex-center it__hs-assignee">
- <div className="big-spacer-right">{translate('assignee')}:</div>
- <Assignee hotspot={hotspot} onAssigneeChange={props.onUpdateHotspot} />
+ <Header
+ className="sw-sticky sw--mx-6 sw--mt-6 sw-px-6 sw-pt-6 sw-z-filterbar-header"
+ style={{ top: `${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT - 2}px` }}
+ >
+ <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4 sw-pb-4">
+ <div className="sw-flex-1">
+ <StyledPageTitle as="h2" className="sw-whitespace-normal sw-overflow-visible">
+ <LightPrimary>
+ <IssueMessageHighlighting message={message} messageFormattings={messageFormattings} />
+ </LightPrimary>
+ <ClipboardIconButton
+ Icon={LinkIcon}
+ className="sw-ml-2"
+ copyValue={permalink}
+ discreet={true}
+ />
+ </StyledPageTitle>
+ <div className="sw-mt-2 sw-mb-4 sw-body-sm">
+ <LightLabel>{rule.name}</LightLabel>
+ <Link className="sw-ml-1" to={getRuleUrl(rule.key)} target="_blank">
+ {rule.key}
+ </Link>
</div>
+ <Status
+ hotspot={hotspot}
+ onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)}
+ />
+ </div>
+ <div className="sw-flex sw-flex-col sw-gap-4">
+ <HotspotHeaderRightSection
+ hotspot={hotspot}
+ categoryStandard={categoryStandard}
+ onUpdateHotspot={props.onUpdateHotspot}
+ />
</div>
</div>
- </div>
+ </Header>
);
}
+
+const Header = withTheme(styled.div`
+ background-color: ${themeColor('pageBlock')};
+`);
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { HotspotRating, LightLabel } from 'design-system';
+import React from 'react';
+import Tooltip from '../../../components/controls/Tooltip';
+import { translate } from '../../../helpers/l10n';
+import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
+import Assignee from './Assignee';
+
+interface Props {
+ hotspot: Hotspot;
+ categoryStandard?: string;
+ onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
+}
+
+export default function HotspotHeaderRightSection(props: Props) {
+ const { hotspot, categoryStandard } = props;
+ return (
+ <>
+ <HotspotHeaderInfo title={translate('hotspots.risk_exposure')}>
+ <div className="sw-flex sw-items-center">
+ <HotspotRating className="sw-mr-1" rating={hotspot.rule.vulnerabilityProbability} />
+ <LightLabel className="sw-body-sm">
+ {translate('risk_exposure', hotspot.rule.vulnerabilityProbability)}
+ </LightLabel>
+ </div>
+ </HotspotHeaderInfo>
+ <HotspotHeaderInfo title={translate('category')}>
+ <LightLabel className="sw-body-sm">{categoryStandard}</LightLabel>
+ </HotspotHeaderInfo>
+ {hotspot.codeVariants && hotspot.codeVariants.length > 0 && (
+ <HotspotHeaderInfo title={translate('issues.facet.codeVariants')} className="sw-truncate">
+ <LightLabel className="sw-body-sm">
+ <Tooltip overlay={hotspot.codeVariants.join(', ')}>
+ <span>{hotspot.codeVariants.join(', ')}</span>
+ </Tooltip>
+ </LightLabel>
+ </HotspotHeaderInfo>
+ )}
+ <HotspotHeaderInfo title={translate('assignee')}>
+ <Assignee hotspot={hotspot} onAssigneeChange={props.onUpdateHotspot} />
+ </HotspotHeaderInfo>
+ </>
+ );
+}
+
+interface HotspotHeaderInfoProps {
+ children: React.ReactNode;
+ title: string;
+ className?: string;
+}
+
+function HotspotHeaderInfo({ children, title, className }: HotspotHeaderInfoProps) {
+ return (
+ <div className={classNames('sw-min-w-abs-150 sw-max-w-abs-250', className)}>
+ <div className="sw-body-sm-highlight">{title}:</div>
+ {children}
+ </div>
+ );
+}
import * as React from 'react';
import { getRuleDetails } from '../../../api/rules';
import { getSecurityHotspotDetails } from '../../../api/security-hotspots';
+import { Standards } from '../../../types/security';
import {
Hotspot,
HotspotStatusFilter,
interface Props {
component: Component;
hotspotKey: string;
- hotspotsReviewedMeasure?: string;
onSwitchStatusFilter: (option: HotspotStatusFilter) => void;
onUpdateHotspot: (hotspotKey: string) => Promise<void>;
onLocationClick: (index: number) => void;
selectedHotspotLocation?: number;
+ standards?: Standards;
}
interface State {
ruleDescriptionSections?: RuleDescriptionSection[];
lastStatusChangedTo?: HotspotStatusOption;
loading: boolean;
- showStatusUpdateSuccessModal: boolean;
}
export default class HotspotViewer extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.commentTextRef = React.createRef<HTMLTextAreaElement>();
- this.state = { loading: false, showStatusUpdateSuccessModal: false };
+ this.state = { loading: false };
}
componentDidMount() {
const { hotspotKey } = this.props;
if (statusUpdate) {
- this.setState({ lastStatusChangedTo: statusOption, showStatusUpdateSuccessModal: true });
+ this.setState({ lastStatusChangedTo: statusOption });
await this.props.onUpdateHotspot(hotspotKey);
} else {
await this.fetchHotspot();
}
};
- handleCloseStatusUpdateSuccessModal = () => {
- this.setState({ showStatusUpdateSuccessModal: false });
- };
-
render() {
- const { component, hotspotsReviewedMeasure, selectedHotspotLocation } = this.props;
- const {
- hotspot,
- ruleDescriptionSections,
- lastStatusChangedTo,
- loading,
- showStatusUpdateSuccessModal,
- } = this.state;
+ const { component, selectedHotspotLocation, standards } = this.props;
+ const { hotspot, ruleDescriptionSections, loading } = this.state;
return (
<HotspotViewerRenderer
+ standards={standards}
component={component}
commentTextRef={this.commentTextRef}
hotspot={hotspot}
ruleDescriptionSections={ruleDescriptionSections}
- hotspotsReviewedMeasure={hotspotsReviewedMeasure}
- lastStatusChangedTo={lastStatusChangedTo}
loading={loading}
- onCloseStatusUpdateSuccessModal={this.handleCloseStatusUpdateSuccessModal}
- onSwitchFilterToStatusOfUpdatedHotspot={this.handleSwitchFilterToStatusOfUpdatedHotspot}
onShowCommentForm={this.handleScrollToCommentForm}
onUpdateHotspot={this.handleHotspotUpdate}
onLocationClick={this.props.onLocationClick}
- showStatusUpdateSuccessModal={showStatusUpdateSuccessModal}
selectedHotspotLocation={selectedHotspotLocation}
/>
);
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
import DeferredSpinner from '../../../components/ui/DeferredSpinner';
import { fillBranchLike } from '../../../helpers/branch-like';
+import { Standards } from '../../../types/security';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
import { CurrentUser } from '../../../types/users';
import HotspotSnippetContainer from './HotspotSnippetContainer';
import './HotspotViewer.css';
import HotspotViewerTabs from './HotspotViewerTabs';
-import StatusUpdateSuccessModal from './StatusUpdateSuccessModal';
export interface HotspotViewerRendererProps {
component: Component;
currentUser: CurrentUser;
hotspot?: Hotspot;
ruleDescriptionSections?: RuleDescriptionSection[];
- hotspotsReviewedMeasure?: string;
- lastStatusChangedTo?: HotspotStatusOption;
loading: boolean;
commentTextRef: React.RefObject<HTMLTextAreaElement>;
- onCloseStatusUpdateSuccessModal: () => void;
onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
onShowCommentForm: () => void;
- onSwitchFilterToStatusOfUpdatedHotspot: () => void;
onLocationClick: (index: number) => void;
- showStatusUpdateSuccessModal: boolean;
selectedHotspotLocation?: number;
+ standards?: Standards;
}
export function HotspotViewerRenderer(props: HotspotViewerRendererProps) {
component,
currentUser,
hotspot,
- hotspotsReviewedMeasure,
loading,
- lastStatusChangedTo,
- showStatusUpdateSuccessModal,
commentTextRef,
selectedHotspotLocation,
ruleDescriptionSections,
+ standards,
} = props;
+ const branchLike = hotspot && fillBranchLike(hotspot.project.branch, hotspot.project.pullRequest);
+
return (
<DeferredSpinner className="big-spacer-left big-spacer-top" loading={loading}>
- {showStatusUpdateSuccessModal && (
- <StatusUpdateSuccessModal
- hotspotsReviewedMeasure={hotspotsReviewedMeasure}
- lastStatusChangedTo={lastStatusChangedTo}
- onClose={props.onCloseStatusUpdateSuccessModal}
- onSwitchFilterToStatusOfUpdatedHotspot={props.onSwitchFilterToStatusOfUpdatedHotspot}
- />
- )}
-
{hotspot && (
- <div className="big-padded hotspot-content">
- <HotspotHeader hotspot={hotspot} onUpdateHotspot={props.onUpdateHotspot} />
+ <div className="sw-box-border sw-p-6">
+ <HotspotHeader
+ hotspot={hotspot}
+ component={component}
+ standards={standards}
+ onUpdateHotspot={props.onUpdateHotspot}
+ branchLike={branchLike}
+ />
<HotspotViewerTabs
codeTabContent={
<HotspotSnippetContainer
- branchLike={fillBranchLike(hotspot.project.branch, hotspot.project.pullRequest)}
+ branchLike={branchLike}
component={component}
hotspot={hotspot}
onCommentButtonClick={props.onShowCommentForm}
+++ /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 * as React from 'react';
-import { assignSecurityHotspot } from '../../../../api/security-hotspots';
-import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import { addGlobalSuccessMessage } from '../../../../helpers/globalMessages';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import { Hotspot, HotspotResolution, HotspotStatus } from '../../../../types/security-hotspots';
-import { CurrentUser, isLoggedIn, UserActive } from '../../../../types/users';
-import AssigneeRenderer from './AssigneeRenderer';
-
-interface Props {
- currentUser: CurrentUser;
- hotspot: Hotspot;
-
- onAssigneeChange: () => void;
-}
-
-interface State {
- editing: boolean;
- loading: boolean;
-}
-
-export class Assignee extends React.PureComponent<Props, State> {
- mounted = false;
- state = {
- editing: false,
- loading: false,
- };
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleEnterEditionMode = () => {
- this.setState({ editing: true });
- };
-
- handleExitEditionMode = () => {
- this.setState({ editing: false });
- };
-
- handleAssign = (newAssignee: UserActive) => {
- this.setState({ loading: true });
- assignSecurityHotspot(this.props.hotspot.key, {
- assignee: newAssignee?.login,
- })
- .then(() => {
- if (this.mounted) {
- this.setState({ editing: false, loading: false });
- this.props.onAssigneeChange();
- }
- })
- .then(() =>
- addGlobalSuccessMessage(
- newAssignee.login
- ? translateWithParameters('hotspots.assign.success', newAssignee.name)
- : translate('hotspots.assign.unassign.success')
- )
- )
- .catch(() => this.setState({ loading: false }));
- };
-
- render() {
- const {
- currentUser,
- hotspot: { assigneeUser, status, resolution },
- } = this.props;
- const { editing, loading } = this.state;
-
- const canEdit =
- status === HotspotStatus.TO_REVIEW || resolution === HotspotResolution.ACKNOWLEDGED;
-
- return (
- <AssigneeRenderer
- assignee={assigneeUser}
- canEdit={canEdit}
- editing={editing}
- loading={loading}
- loggedInUser={isLoggedIn(currentUser) ? currentUser : undefined}
- onAssign={this.handleAssign}
- onEnterEditionMode={this.handleEnterEditionMode}
- onExitEditionMode={this.handleExitEditionMode}
- />
- );
- }
-}
-
-export default withCurrentUserContext(Assignee);
+++ /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 * as React from 'react';
-import { EditButton } from '../../../../components/controls/buttons';
-import EscKeydownHandler from '../../../../components/controls/EscKeydownHandler';
-import OutsideClickHandler from '../../../../components/controls/OutsideClickHandler';
-import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
-import { translate, translateWithParameters } from '../../../../helpers/l10n';
-import { LoggedInUser, UserActive, UserBase } from '../../../../types/users';
-import AssigneeSelection from './AssigneeSelection';
-
-export interface AssigneeRendererProps {
- canEdit: boolean;
- editing: boolean;
- loading: boolean;
-
- assignee?: UserBase;
- loggedInUser?: LoggedInUser;
-
- onAssign: (user: UserActive) => void;
- onEnterEditionMode: () => void;
- onExitEditionMode: () => void;
-}
-
-export default function AssigneeRenderer(props: AssigneeRendererProps) {
- const { assignee, canEdit, loggedInUser, editing, loading } = props;
-
- return (
- <DeferredSpinner loading={loading}>
- {!editing && (
- <div className="display-flex-center">
- <strong className="nowrap" data-testid="assignee-name">
- {assignee &&
- (assignee.active
- ? assignee.name ?? assignee.login
- : translateWithParameters('user.x_deleted', assignee.name ?? assignee.login))}
- {!assignee && translate('unassigned')}
- </strong>
- {loggedInUser && canEdit && (
- <EditButton
- aria-label={translate('hotspots.assignee.change_user')}
- className="spacer-left"
- onClick={props.onEnterEditionMode}
- />
- )}
- </div>
- )}
-
- {loggedInUser && editing && (
- <EscKeydownHandler onKeydown={props.onExitEditionMode}>
- <OutsideClickHandler onClickOutside={props.onExitEditionMode}>
- <AssigneeSelection
- allowCurrentUserSelection={loggedInUser.login !== assignee?.login}
- loggedInUser={loggedInUser}
- onSelect={props.onAssign}
- />
- </OutsideClickHandler>
- </EscKeydownHandler>
- )}
- </DeferredSpinner>
- );
-}
+++ /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.
- */
-.hotspot-assignee-search-results {
- min-width: 300px;
-}
-
-.hotspot-assignee-search-results li {
- cursor: pointer;
-}
-
-.hotspot-assignee-search-results li:hover,
-.hotspot-assignee-search-results li.active {
- background-color: var(--barBackgroundColor);
-}
+++ /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 { debounce } from 'lodash';
-import * as React from 'react';
-import { searchUsers } from '../../../../api/users';
-import { KeyboardKeys } from '../../../../helpers/keycodes';
-import { translate } from '../../../../helpers/l10n';
-import { isUserActive, LoggedInUser, UserActive } from '../../../../types/users';
-import AssigneeSelectionRenderer from './AssigneeSelectionRenderer';
-
-interface Props {
- allowCurrentUserSelection: boolean;
- loggedInUser: LoggedInUser;
- onSelect: (user: UserActive) => void;
-}
-
-interface State {
- highlighted?: UserActive;
- loading: boolean;
- query?: string;
- suggestedUsers: UserActive[];
-}
-
-const UNASSIGNED: UserActive = { login: '', name: translate('unassigned') };
-
-export default class AssigneeSelection extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
-
- this.state = {
- loading: false,
- suggestedUsers: props.allowCurrentUserSelection
- ? [props.loggedInUser, UNASSIGNED]
- : [UNASSIGNED],
- };
-
- this.handleSearch = debounce(this.handleSearch, 250);
- }
-
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleSearch = (query: string) => {
- if (this.mounted) {
- if (query.length < 2) {
- this.handleNoSearch(query);
- } else {
- this.handleActualSearch(query);
- }
- }
- };
-
- handleNoSearch = (query: string) => {
- const { allowCurrentUserSelection, loggedInUser } = this.props;
-
- this.setState({
- loading: false,
- query,
- suggestedUsers: allowCurrentUserSelection ? [loggedInUser, UNASSIGNED] : [UNASSIGNED],
- });
- };
-
- handleActualSearch = (query: string) => {
- this.setState({ loading: true, query });
- searchUsers({ q: query })
- .then((result) => {
- if (this.mounted) {
- this.setState({
- loading: false,
- query,
- suggestedUsers: (result.users.filter(isUserActive) as UserActive[]).concat(UNASSIGNED),
- });
- }
- })
- .catch(() => {
- if (this.mounted) {
- this.setState({ loading: false });
- }
- });
- };
-
- handleKeyDown = (event: React.KeyboardEvent) => {
- switch (event.nativeEvent.key) {
- case KeyboardKeys.Enter:
- event.preventDefault();
- this.selectHighlighted();
- break;
- case KeyboardKeys.UpArrow:
- event.preventDefault();
- event.nativeEvent.stopImmediatePropagation();
- this.highlightPrevious();
- break;
- case KeyboardKeys.DownArrow:
- event.preventDefault();
- event.nativeEvent.stopImmediatePropagation();
- this.highlightNext();
- break;
- }
- };
-
- getCurrentIndex = () => {
- const { highlighted, suggestedUsers } = this.state;
-
- return highlighted
- ? suggestedUsers.findIndex((suggestion) => suggestion.login === highlighted.login)
- : -1;
- };
-
- highlightIndex = (index: number) => {
- const { suggestedUsers } = this.state;
-
- if (suggestedUsers.length > 0) {
- if (index < 0) {
- index = suggestedUsers.length - 1;
- } else if (index >= suggestedUsers.length) {
- index = 0;
- }
-
- this.setState({ highlighted: suggestedUsers[index] });
- }
- };
-
- highlightPrevious = () => {
- this.highlightIndex(this.getCurrentIndex() - 1);
- };
-
- highlightNext = () => {
- this.highlightIndex(this.getCurrentIndex() + 1);
- };
-
- selectHighlighted = () => {
- const { highlighted } = this.state;
-
- if (highlighted !== undefined) {
- this.props.onSelect(highlighted);
- }
- };
-
- render() {
- const { highlighted, loading, query, suggestedUsers } = this.state;
-
- return (
- <AssigneeSelectionRenderer
- highlighted={highlighted}
- loading={loading}
- onKeyDown={this.handleKeyDown}
- onSearch={this.handleSearch}
- onSelect={this.props.onSelect}
- query={query}
- suggestedUsers={suggestedUsers}
- />
- );
- }
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import classNames from 'classnames';
-import * as React from 'react';
-import { DropdownOverlay } from '../../../../components/controls/Dropdown';
-import SearchBox from '../../../../components/controls/SearchBox';
-import Avatar from '../../../../components/ui/Avatar';
-import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
-import { PopupPlacement } from '../../../../components/ui/popups';
-import { translate } from '../../../../helpers/l10n';
-import { UserActive } from '../../../../types/users';
-import './AssigneeSelection.css';
-
-export interface HotspotAssigneeSelectRendererProps {
- highlighted?: UserActive;
- loading: boolean;
- onKeyDown: (event: React.KeyboardEvent) => void;
- onSearch: (query: string) => void;
- onSelect: (user?: UserActive) => void;
- query?: string;
- suggestedUsers?: UserActive[];
-}
-
-export default function AssigneeSelectionRenderer(props: HotspotAssigneeSelectRendererProps) {
- const { highlighted, loading, query, suggestedUsers } = props;
-
- return (
- <div className="dropdown">
- <div className="display-flex-center">
- <SearchBox
- autoFocus={true}
- onChange={props.onSearch}
- onKeyDown={props.onKeyDown}
- placeholder={translate('hotspots.assignee.select_user')}
- value={query}
- />
- {loading && <DeferredSpinner className="spacer-left" />}
- </div>
-
- {!loading && (
- <DropdownOverlay noPadding={true} placement={PopupPlacement.BottomLeft}>
- <ul className="hotspot-assignee-search-results">
- {suggestedUsers &&
- suggestedUsers.map((suggestion) => (
- <li
- className={classNames('padded', {
- active: highlighted && highlighted.login === suggestion.login,
- })}
- key={suggestion.login}
- onClick={() => props.onSelect(suggestion)}
- >
- {suggestion.login && (
- <Avatar
- className="spacer-right"
- hash={suggestion.avatar}
- name={suggestion.name}
- size={16}
- />
- )}
- {suggestion.name}
- </li>
- ))}
- </ul>
- </DropdownOverlay>
- )}
- </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 { ButtonPrimary, HighlightedSection } from 'design-system';
import * as React from 'react';
import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import { Button } from '../../../../components/controls/buttons';
-import { DropdownOverlay } from '../../../../components/controls/Dropdown';
-import Toggler from '../../../../components/controls/Toggler';
import Tooltip from '../../../../components/controls/Tooltip';
-import DropdownIcon from '../../../../components/icons/DropdownIcon';
-import { PopupPlacement } from '../../../../components/ui/popups';
import { translate } from '../../../../helpers/l10n';
import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
import { CurrentUser, isLoggedIn } from '../../../../types/users';
export interface StatusProps {
currentUser: CurrentUser;
hotspot: Hotspot;
-
onStatusChange: (statusOption: HotspotStatusOption) => Promise<void>;
}
const { currentUser, hotspot } = props;
const [isOpen, setIsOpen] = React.useState(false);
- const [comment, setComment] = React.useState('');
-
- React.useEffect(() => {
- setComment('');
- }, [hotspot.key]);
-
const statusOption = getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution);
const readonly = !hotspot.canChangeStatus || !isLoggedIn(currentUser);
return (
- <div className="display-flex-row display-flex-end">
- <StatusDescription showTitle={true} statusOption={statusOption} />
- <div className="spacer-top">
+ <>
+ <HighlightedSection className="sw-flex sw-rounded-1 sw-p-4 sw-items-center sw-justify-between sw-gap-2 sw-flex-row">
+ <StatusDescription statusOption={statusOption} />
<Tooltip
overlay={readonly ? translate('hotspots.status.cannot_change_status') : null}
placement="bottom"
>
- <div className="dropdown">
- <Toggler
- closeOnClickOutside={true}
- closeOnEscape={true}
- onRequestClose={() => setIsOpen(false)}
- open={isOpen}
- overlay={
- <DropdownOverlay noPadding={true} placement={PopupPlacement.Bottom}>
- <StatusSelection
- hotspot={hotspot}
- onStatusOptionChange={async (status) => {
- await props.onStatusChange(status);
- setIsOpen(false);
- }}
- comment={comment}
- setComment={setComment}
- />
- </DropdownOverlay>
- }
- >
- <Button
- className="dropdown-toggle big-spacer-left"
- id="status-trigger"
- onClick={() => setIsOpen(true)}
- disabled={readonly}
- >
- <span>{translate('hotspots.status.select_status')}</span>
- <DropdownIcon className="little-spacer-left" />
- </Button>
- </Toggler>
- </div>
+ <ButtonPrimary id="status-trigger" onClick={() => setIsOpen(true)} disabled={readonly}>
+ {translate('hotspots.status.review')}
+ </ButtonPrimary>
</Tooltip>
- </div>
- </div>
+ </HighlightedSection>
+ {isOpen && (
+ <StatusSelection
+ hotspot={hotspot}
+ onClose={() => setIsOpen(false)}
+ onStatusOptionChange={props.onStatusChange}
+ />
+ )}
+ </>
);
}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import styled from '@emotion/styled';
-import classNames from 'classnames';
+import { LightLabel, LightPrimary } from 'design-system';
import * as React from 'react';
import { translate } from '../../../../helpers/l10n';
import { HotspotStatusOption } from '../../../../types/security-hotspots';
export interface StatusDescriptionProps {
statusOption: HotspotStatusOption;
- showTitle?: boolean;
- statusInBadge?: boolean;
}
export default function StatusDescription(props: StatusDescriptionProps) {
- const { statusOption, showTitle, statusInBadge = true } = props;
+ const { statusOption } = props;
return (
- <Container>
+ <div>
<h3>
- {showTitle && `${translate('status')}: `}
- <div className={classNames({ badge: statusInBadge })}>
+ <LightPrimary className="sw-body-sm-highlight">
+ {`${translate('status')}: `}
{translate('hotspots.status_option', statusOption)}
- </div>
+ </LightPrimary>
</h3>
- <div className="little-spacer-top">
- {translate('hotspots.status_option', statusOption, 'description')}
- </div>
- </Container>
+ <Description className="sw-mt-1">
+ <LightLabel className="sw-body-sm">
+ {translate('hotspots.status_option', statusOption, 'description')}
+ </LightLabel>
+ </Description>
+ </div>
);
}
-const Container = styled.div`
- width: 350px;
+const Description = styled.div`
+ max-width: 360px;
`;
*/
import * as React from 'react';
import { setSecurityHotspotStatus } from '../../../../api/security-hotspots';
+import { addGlobalSuccessMessage } from '../../../../helpers/globalMessages';
+import { translate, translateWithParameters } from '../../../../helpers/l10n';
import { Hotspot, HotspotStatusOption } from '../../../../types/security-hotspots';
import {
getStatusAndResolutionFromStatusOption,
interface Props {
hotspot: Hotspot;
onStatusOptionChange: (statusOption: HotspotStatusOption) => Promise<void>;
- comment: string;
- setComment: (comment: string) => void;
+ onClose: () => void;
}
-interface State {
- loading: boolean;
- initialStatus: HotspotStatusOption;
- selectedStatus: HotspotStatusOption;
-}
-
-export default class StatusSelection extends React.PureComponent<Props, State> {
- mounted = false;
-
- constructor(props: Props) {
- super(props);
-
- const initialStatus = getStatusOptionFromStatusAndResolution(
- props.hotspot.status,
- props.hotspot.resolution
- );
-
- this.state = {
- loading: false,
- initialStatus,
- selectedStatus: initialStatus,
- };
- }
+export default function StatusSelection(props: Props) {
+ const { hotspot } = props;
+ const initialStatus = React.useMemo(
+ () => getStatusOptionFromStatusAndResolution(hotspot.status, hotspot.resolution),
+ [hotspot]
+ );
- componentDidMount() {
- this.mounted = true;
- }
+ const [loading, setLoading] = React.useState(false);
+ const [status, setStatus] = React.useState(initialStatus);
+ const [comment, setComment] = React.useState('');
- componentWillUnmount() {
- this.mounted = false;
- }
+ const submitDisabled = status === initialStatus;
- handleStatusChange = (selectedStatus: HotspotStatusOption) => {
- this.setState({ selectedStatus });
- };
-
- handleCommentChange = (comment: string) => {
- this.props.setComment(comment);
- };
+ const handleSubmit = async () => {
+ const { hotspot } = props;
- handleSubmit = () => {
- const { hotspot, comment } = this.props;
- const { initialStatus, selectedStatus } = this.state;
-
- if (selectedStatus && selectedStatus !== initialStatus) {
- this.setState({ loading: true });
- setSecurityHotspotStatus(hotspot.key, {
- ...getStatusAndResolutionFromStatusOption(selectedStatus),
- comment: comment || undefined,
- })
- .then(async () => {
- await this.props.onStatusOptionChange(selectedStatus);
- if (this.mounted) {
- this.setState({ loading: false });
- }
- })
- .catch(() => this.setState({ loading: false }));
+ if (status !== initialStatus) {
+ setLoading(true);
+ try {
+ await setSecurityHotspotStatus(hotspot.key, {
+ ...getStatusAndResolutionFromStatusOption(status),
+ comment: comment || undefined,
+ });
+ await props.onStatusOptionChange(status);
+ addGlobalSuccessMessage(
+ translateWithParameters(
+ 'hotspots.update.success',
+ translate('hotspots.status_option', status)
+ )
+ );
+ props.onClose();
+ } catch {
+ setLoading(false);
+ }
}
};
- render() {
- const { comment } = this.props;
- const { initialStatus, loading, selectedStatus } = this.state;
- const submitDisabled = selectedStatus === initialStatus;
-
- return (
- <StatusSelectionRenderer
- comment={comment}
- loading={loading}
- onCommentChange={this.handleCommentChange}
- onStatusChange={this.handleStatusChange}
- onSubmit={this.handleSubmit}
- selectedStatus={selectedStatus}
- submitDisabled={submitDisabled}
- />
- );
- }
+ return (
+ <StatusSelectionRenderer
+ comment={comment}
+ loading={loading}
+ onCommentChange={(comment) => setComment(comment)}
+ onStatusChange={(status) => {
+ setStatus(status);
+ }}
+ onSubmit={handleSubmit}
+ onCancel={props.onClose}
+ status={status}
+ submitDisabled={submitDisabled}
+ />
+ );
}
* 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 {
+ ButtonPrimary,
+ ButtonSecondary,
+ DeferredSpinner,
+ FormField,
+ InputTextArea,
+ LightPrimary,
+ Note,
+ SelectionCard,
+} from 'design-system';
import * as React from 'react';
import FormattingTips from '../../../../components/common/FormattingTips';
-import { SubmitButton } from '../../../../components/controls/buttons';
-import Radio from '../../../../components/controls/Radio';
+import Modal from '../../../../components/controls/Modal';
import { translate } from '../../../../helpers/l10n';
import { HotspotStatusOption } from '../../../../types/security-hotspots';
-import StatusDescription from './StatusDescription';
export interface StatusSelectionRendererProps {
- selectedStatus: HotspotStatusOption;
+ status: HotspotStatusOption;
onStatusChange: (statusOption: HotspotStatusOption) => void;
-
comment?: string;
onCommentChange: (comment: string) => void;
-
- onSubmit: () => void;
-
+ onCancel: () => void;
+ onSubmit: () => Promise<void>;
loading: boolean;
submitDisabled: boolean;
}
export default function StatusSelectionRenderer(props: StatusSelectionRendererProps) {
- const { comment, loading, selectedStatus, submitDisabled } = props;
+ const { comment, loading, status, submitDisabled } = props;
- const renderOption = (status: HotspotStatusOption) => {
+ const renderOption = (statusOption: HotspotStatusOption) => {
return (
- <Radio
- checked={selectedStatus === status}
- className="big-spacer-bottom status-radio"
- alignLabel={true}
- onCheck={props.onStatusChange}
- value={status}
+ <SelectionCard
+ className="sw-mb-3"
+ key={statusOption}
+ onClick={() => props.onStatusChange(statusOption)}
+ selected={statusOption === status}
+ title={translate('hotspots.status_option', statusOption)}
+ vertical={true}
>
- <StatusDescription statusOption={status} statusInBadge={false} />
- </Radio>
+ <Note className="sw-mt-1 sw-mr-12">
+ {translate('hotspots.status_option', statusOption, 'description')}
+ </Note>
+ </SelectionCard>
);
};
return (
- <div data-testid="security-hotspot-test" className="abs-width-400">
- <div className="big-padded">
+ <Modal contentLabel={translate('hotspots.status.review_title')}>
+ <header className="sw-p-9">
+ <h1 className="sw-heading-lg sw-mb-2">{translate('hotspots.status.review_title')}</h1>
+ <LightPrimary as="p" className="sw-body-sm">
+ {translate('hotspots.status.select')}
+ </LightPrimary>
+ </header>
+ <MainStyled className="sw-px-9">
{renderOption(HotspotStatusOption.TO_REVIEW)}
{renderOption(HotspotStatusOption.ACKNOWLEDGED)}
{renderOption(HotspotStatusOption.FIXED)}
{renderOption(HotspotStatusOption.SAFE)}
- </div>
-
- <hr />
- <div className="big-padded display-flex-column">
- <label className="text-bold" htmlFor="comment-textarea">
- {translate('hotspots.status.add_comment')}
- </label>
- <textarea
- className="spacer-top form-field fixed-width spacer-bottom"
- id="comment-textarea"
- onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
- props.onCommentChange(event.currentTarget.value)
- }
- rows={4}
- value={comment}
- />
- <FormattingTips />
-
- <div className="big-spacer-top display-flex-justify-end display-flex-center">
- <SubmitButton disabled={submitDisabled || loading} onClick={props.onSubmit}>
- {translate('hotspots.status.change_status')}
- </SubmitButton>
-
- {loading && <i className="spacer-left spinner" />}
- </div>
- </div>
- </div>
+ <FormField htmlFor="comment-textarea" label={translate('hotspots.status.add_comment')}>
+ <InputTextArea
+ className="sw-mb-2 sw-resize-y"
+ id="comment-textarea"
+ onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
+ props.onCommentChange(event.currentTarget.value)
+ }
+ rows={4}
+ size="full"
+ value={comment}
+ />
+ <FormattingTips />
+ </FormField>
+ </MainStyled>
+ <footer className="sw-flex sw-justify-end sw-items-center sw-gap-3 sw-p-9">
+ <DeferredSpinner loading={loading} />
+ <ButtonPrimary disabled={submitDisabled || loading} onClick={props.onSubmit}>
+ {translate('hotspots.status.change_status')}
+ </ButtonPrimary>
+ <ButtonSecondary onClick={props.onCancel}>{translate('cancel')}</ButtonSecondary>
+ </footer>
+ </Modal>
);
}
+
+const MainStyled = styled.main`
+ max-height: calc(100vh - 400px);
+ overflow: auto;
+`;
margin-left: 0;
}
}
-
-#security_hotspots .hotspot-content {
- max-width: 962px; /* 1280px - 300px - 18px */
- background: white;
- box-sizing: border-box;
-}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import classNames from 'classnames';
-import * as React from 'react';
+import { Link, Note } from 'design-system';
+import React from 'react';
import { translate } from '../../helpers/l10n';
import { getFormattingHelpUrl } from '../../helpers/urls';
className?: string;
}
-export default class FormattingTips extends React.PureComponent<Props> {
- handleClick(evt: React.SyntheticEvent<HTMLAnchorElement>) {
+export default function FormattingTips({ className }: Props) {
+ const handleClick = React.useCallback((evt: React.MouseEvent<HTMLAnchorElement>) => {
evt.preventDefault();
window.open(
getFormattingHelpUrl(),
'Formatting',
'height=300,width=600,scrollbars=1,resizable=1'
);
- }
+ }, []);
- render() {
- return (
- <div className={classNames('markdown-tips', this.props.className)}>
- <a className="little-spacer-right" href="#" onClick={this.handleClick}>
- {translate('formatting.helplink')}
- </a>
- {':'}
- <span className="spacer-left">*{translate('bold')}*</span>
- <span className="spacer-left">
- ``
- {translate('code')}
- ``
- </span>
- <span className="spacer-left">* {translate('bulleted_point')}</span>
- </div>
- );
- }
+ return (
+ <Note className={className}>
+ <Link className="sw-mr-1" onClick={handleClick} to={getFormattingHelpUrl()}>
+ {translate('formatting.helplink')}
+ </Link>
+ {':'}
+ <span className="sw-ml-2">*{translate('bold')}*</span>
+ <span className="sw-ml-2">
+ ``
+ {translate('code')}
+ ``
+ </span>
+ <span className="sw-ml-2">* {translate('bulleted_point')}</span>
+ </Note>
+ );
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { shallow } from 'enzyme';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
-import { click } from '../../../helpers/testUtils';
+import { renderComponent } from '../../../helpers/testReactTestingUtils';
+import { FCProps } from '../../../types/misc';
import FormattingTips from '../FormattingTips';
const originalOpen = window.open;
});
});
-it('should render correctly', () => {
- expect(shallowRender()).toMatchSnapshot();
-});
-
-it('should correctly open a new window', () => {
- const wrapper = shallowRender();
- expect(window.open).not.toHaveBeenCalled();
- click(wrapper.find('a'));
+it('should render correctly', async () => {
+ const user = userEvent.setup();
+ renderFormattingTips();
+ const link = screen.getByRole('link', { name: 'formatting.helplink' });
+ expect(link).toBeInTheDocument();
+ await user.click(link);
expect(window.open).toHaveBeenCalled();
});
-function shallowRender(props: Partial<FormattingTips['props']> = {}) {
- return shallow<FormattingTips>(<FormattingTips {...props} />);
+function renderFormattingTips(props: Partial<FCProps<typeof FormattingTips>> = {}) {
+ return renderComponent(<FormattingTips {...props} />);
}
+++ /dev/null
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly 1`] = `
-<div
- className="markdown-tips"
->
- <a
- className="little-spacer-right"
- href="#"
- onClick={[Function]}
- >
- formatting.helplink
- </a>
- :
- <span
- className="spacer-left"
- >
- *
- bold
- *
- </span>
- <span
- className="spacer-left"
- >
- \`\`
- code
- \`\`
- </span>
- <span
- className="spacer-left"
- >
- *
- bulleted_point
- </span>
-</div>
-`;
--- /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 type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0];
hotspots.assignee.select_user=Select a user...
hotspots.assignee.change_user=Click to change assignee
hotspots.status.cannot_change_status=Changing a hotspot's status requires permission.
-hotspots.status.select_status=Change status
+hotspots.status.review=Review
+hotspots.status.review_title=Review Security Hotspot
+hotspots.status.select=Select a status
hotspots.status.add_comment=Add a comment (Optional)
hotspots.status.change_status=Change status
+
hotspots.status_option.TO_REVIEW=To review
hotspots.status_option.TO_REVIEW.description=This security hotspot needs to be reviewed to assess whether the code poses a risk.
hotspots.status_option.ACKNOWLEDGED=Acknowledged
hotspots.assign.success=Security Hotspot was successfully assigned to {0}
hotspots.assign.unassign.success=Security Hotspot was successfully unassigned
-hotspots.update.success=Update successful
+hotspots.update.success=Security Hotspot status was successfully changed to {0}
#------------------------------------------------------------------------------
#
search.search_for_files=Search for files...
search.search_for_modules=Search for modules...
search.search_for_metrics=Search for metrics...
+search.tooShort=Please enter at least {0} characters
global_search.shortcut_hint=Hint: Press 'S' from anywhere to open this search bar.