--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 {
+ IconCheckCircle,
+ IconError,
+ IconInfo,
+ IconRecommended,
+ IconWarning,
+ IconX,
+} from '@sonarsource/echoes-react';
+import classNames from 'classnames';
+import { HTMLAttributes } from 'react';
+import { IntlShape, useIntl } from 'react-intl';
+import tw from 'twin.macro';
+import { ThemeColors } from '~types/theme';
+import { themeBorder, themeColor } from '../../helpers/theme';
+
+export type FlagMessageV2Variant = 'error' | 'warning' | 'success' | 'info' | 'recommended';
+
+interface Props {
+ hasIcon?: boolean;
+ onDismiss?: () => void;
+ title?: string;
+ variant: FlagMessageV2Variant;
+}
+
+interface VariantInformation {
+ backGroundColor: ThemeColors;
+ borderColor: ThemeColors;
+ icon: JSX.Element;
+ iconColor: ThemeColors;
+ iconFocusBackground: ThemeColors;
+ iconHover: ThemeColors;
+ iconHoverBackground: ThemeColors;
+}
+
+function getAlertVariantInfo(variant: FlagMessageV2Variant, intl: IntlShape): VariantInformation {
+ const variantList: Record<FlagMessageV2Variant, VariantInformation> = {
+ error: {
+ icon: <IconError aria-label={intl.formatMessage({ id: 'flagmessage.tooltip.error' })} />,
+ borderColor: 'errorBorder',
+ backGroundColor: 'errorBackground',
+ iconColor: 'errorIcon',
+ iconHover: 'errorIconHover',
+ iconHoverBackground: 'errorIconHoverBackground',
+ iconFocusBackground: 'errorIconFocusBackground',
+ },
+ warning: {
+ icon: <IconWarning aria-label={intl.formatMessage({ id: 'flagmessage.tooltip.warning' })} />,
+ borderColor: 'warningBorder',
+ backGroundColor: 'warningBackground',
+ iconColor: 'warningIcon',
+ iconHover: 'warningIconHover',
+ iconHoverBackground: 'warningIconHoverBackground',
+ iconFocusBackground: 'warningIconFocusBackground',
+ },
+ success: {
+ icon: (
+ <IconCheckCircle aria-label={intl.formatMessage({ id: 'flagmessage.tooltip.success' })} />
+ ),
+ borderColor: 'successBorder',
+ backGroundColor: 'successBackground',
+ iconColor: 'successIcon',
+ iconHover: 'successIconHover',
+ iconHoverBackground: 'successIconHoverBackground',
+ iconFocusBackground: 'successIconFocusBackground',
+ },
+ info: {
+ icon: <IconInfo aria-label={intl.formatMessage({ id: 'flagmessage.tooltip.info' })} />,
+ borderColor: 'infoBorder',
+ backGroundColor: 'infoBackground',
+ iconColor: 'infoIcon',
+ iconHover: 'infoIconHover',
+ iconHoverBackground: 'infoIconHoverBackground',
+ iconFocusBackground: 'infoIconFocusBackground',
+ },
+ recommended: {
+ icon: <IconRecommended aria-label={intl.formatMessage({ id: 'flagmessage.tooltip.info' })} />,
+ borderColor: 'recommendedBorder',
+ backGroundColor: 'recommendedBackground',
+ iconColor: 'recommendedIcon',
+ iconHover: 'recommendedIconHover',
+ iconHoverBackground: 'recommendedIconHoverBackground',
+ iconFocusBackground: 'recommendedIconFocusBackground',
+ },
+ };
+
+ return variantList[variant];
+}
+
+export function FlagMessageV2(props: Readonly<Props & HTMLAttributes<HTMLDivElement>>) {
+ const { className, children, hasIcon = true, onDismiss, title, variant, ...domProps } = props;
+ const intl = useIntl();
+ const variantInfo = getAlertVariantInfo(variant, intl);
+
+ return (
+ <StyledFlag
+ className={classNames('js-flag-message', className)}
+ role="alert"
+ variantInfo={variantInfo}
+ {...domProps}
+ >
+ {hasIcon && <IconWrapper variantInfo={variantInfo}>{variantInfo.icon}</IconWrapper>}
+ <div className="sw-flex sw-flex-col sw-gap-2">
+ {title && <Title>{title}</Title>}
+ <StyledFlagContent>{children}</StyledFlagContent>
+ </div>
+ {onDismiss !== undefined && (
+ <DismissButton
+ aria-label={intl.formatMessage({ id: 'close' })}
+ onClick={onDismiss}
+ variantInfo={variantInfo}
+ >
+ <IconX />
+ </DismissButton>
+ )}
+ </StyledFlag>
+ );
+}
+
+const StyledFlag = styled.div<{
+ variantInfo: VariantInformation;
+}>`
+ ${tw`sw-inline-flex sw-gap-1`}
+ ${tw`sw-box-border`}
+ ${tw`sw-px-4 sw-py-2`}
+ ${tw`sw-mb-1`}
+ ${tw`sw-rounded-2`}
+
+ background-color: ${({ variantInfo }) => themeColor(variantInfo.backGroundColor)};
+ border: ${({ variantInfo }) => themeBorder('default', variantInfo.borderColor)};
+`;
+
+const IconWrapper = styled.div<{
+ variantInfo: VariantInformation;
+}>`
+ ${tw`sw-flex`}
+ ${tw`sw-text-[1rem]`}
+ color: ${({ variantInfo }) => themeColor(variantInfo.iconColor)};
+`;
+
+const Title = styled.span`
+ ${tw`sw-body-md-highlight`}
+ color: ${themeColor('flagMessageText')};
+`;
+
+const StyledFlagContent = styled.div`
+ ${tw`sw-pt-1/2`}
+ ${tw`sw-overflow-auto`}
+ ${tw`sw-body-sm`}
+`;
+
+const DismissButton = styled.button<{
+ variantInfo: VariantInformation;
+}>`
+ ${tw`sw-flex sw-justify-center sw-items-center sw-shrink-0`}
+ ${tw`sw-w-6 sw-h-6`}
+ ${tw`sw-box-border`}
+ ${tw`sw-rounded-1`}
+ ${tw`sw-cursor-pointer`}
+ ${tw`sw-border-none`}
+ background: none;
+
+ color: ${({ variantInfo }) => themeColor(variantInfo.iconColor)};
+ transition:
+ box-shadow 0.2s ease,
+ outline 0.2s ease,
+ color 0.2s ease;
+
+ &:focus,
+ &:active {
+ background-color: ${({ theme, variantInfo }) =>
+ `${themeColor(variantInfo.iconFocusBackground)({ theme })}`};
+ box-shadow:
+ 0px 0px 0px 1px ${themeColor('backgroundSecondary')},
+ 0px 0px 0px 3px ${themeColor('flagMessageFocusBackground')};
+ }
+
+ &:hover {
+ color: ${({ theme, variantInfo }) => `${themeColor(variantInfo.iconHover)({ theme })}`};
+ background-color: ${({ theme, variantInfo }) =>
+ `${themeColor(variantInfo.iconHoverBackground)({ theme })}`};
+ }
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { ComponentProps } from 'react';
+import { IntlShape } from 'react-intl';
+import { render } from '../../../helpers/testUtils';
+import { FlagMessageV2, FlagMessageV2Variant } from '../FlagMessageV2';
+
+jest.mock(
+ 'react-intl',
+ () =>
+ ({
+ ...jest.requireActual('react-intl'),
+ useIntl: () => ({
+ formatMessage: ({ id }: { id: string }, values = {}) =>
+ [id, ...Object.values(values)].join('.'),
+ }),
+ }) as IntlShape,
+);
+
+it.each([
+ ['error', '1px solid rgb(249,112,102)'],
+ ['warning', '1px solid rgb(248,205,92)'],
+ ['success', '1px solid rgb(166,208,91)'],
+ ['info', '1px solid rgb(143,202,234)'],
+ ['recommended', '1px solid rgb(93,108,208)'],
+])('should render properly for "%s" variant', (variant: FlagMessageV2Variant, color) => {
+ renderFlagMessage({ variant });
+
+ const item = screen.getByRole('status');
+ expect(item).toBeInTheDocument();
+ expect(item).toHaveStyle({ border: color });
+});
+
+it('should render correctly with optional props', async () => {
+ const onDismiss = jest.fn();
+ const { user } = renderFlagMessage({
+ title: 'This is a title',
+ hasIcon: false,
+ onDismiss,
+ });
+ expect(screen.getByText('This is a title')).toBeInTheDocument();
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button'));
+ expect(onDismiss).toHaveBeenCalled();
+});
+
+function renderFlagMessage(props: Partial<ComponentProps<typeof FlagMessageV2>> = {}) {
+ return render(
+ <FlagMessageV2 role="status" variant="error" {...props}>
+ This is an error!
+ </FlagMessageV2>,
+ );
+}
// flag message
flagMessageBackground: COLORS.white,
+ flagMessageFocusBackground: COLORS.indigo[600],
+ flagMessageText: COLORS.blueGrey[500],
errorBorder: danger.light,
errorBackground: danger.lightest,
+ errorIconBackground: danger.lightest,
errorText: danger.dark,
+ errorIcon: COLORS.red[600],
+ errorIconHover: COLORS.red[800],
+ errorIconHoverBackground: COLORS.red[100],
+ errorIconFocusBackground: COLORS.red[50],
warningBorder: COLORS.yellow[400],
warningBackground: COLORS.yellow[50],
+ warningIconBackground: COLORS.yellow[50],
warningText: COLORS.yellow[900],
+ warningIcon: COLORS.yellow[700],
+ warningIconHover: COLORS.yellow[800],
+ warningIconHoverBackground: COLORS.yellow[100],
+ warningIconFocusBackground: COLORS.yellow[50],
- successBorder: COLORS.green[400],
+ successBorder: COLORS.yellowGreen[400],
successBackground: COLORS.green[50],
+ successIconBackground: COLORS.yellowGreen[50],
successText: COLORS.green[900],
+ successIcon: COLORS.yellowGreen[600],
+ successIconHover: COLORS.yellowGreen[800],
+ successIconHoverBackground: COLORS.yellowGreen[100],
+ successIconFocusBackground: COLORS.yellowGreen[50],
- infoBorder: COLORS.blue[400],
+ infoBorder: COLORS.blue[300],
infoBackground: COLORS.blue[50],
+ infoIconBackground: COLORS.blue[50],
+ infoContrast: COLORS.blue[900],
infoText: COLORS.blue[900],
+ infoIcon: COLORS.blue[600],
+ infoIconHover: COLORS.blue[800],
+ infoIconHoverBackground: COLORS.blue[100],
+ infoIconFocusBackground: COLORS.blue[50],
+
+ recommendedBorder: COLORS.indigo[500],
+ recommendedBackground: COLORS.indigo[50],
+ recommendedIcon: COLORS.indigo[500],
+ recommendedIconHover: COLORS.indigo[800],
+ recommendedIconHoverBackground: COLORS.indigo[50],
+ recommendedIconFocusBackground: COLORS.indigo[50],
// banner message
bannerMessage: danger.lightest,