]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22570 Implement the new Flag message component (#11390)
authorSarath Nair <91882341+sarath-nair-sonarsource@users.noreply.github.com>
Mon, 22 Jul 2024 12:32:54 +0000 (14:32 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 22 Jul 2024 20:02:47 +0000 (20:02 +0000)
server/sonar-web/design-system/src/sonar-aligned/components/FlagMessageV2.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/components/__tests__/FlagMessage-test.tsx
server/sonar-web/design-system/src/sonar-aligned/components/__tests__/FlagMessageV2-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/sonar-aligned/components/index.ts
server/sonar-web/design-system/src/theme/light.ts

diff --git a/server/sonar-web/design-system/src/sonar-aligned/components/FlagMessageV2.tsx b/server/sonar-web/design-system/src/sonar-aligned/components/FlagMessageV2.tsx
new file mode 100644 (file)
index 0000000..6fe7408
--- /dev/null
@@ -0,0 +1,202 @@
+/*
+ * 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 })}`};
+  }
+`;
index 6772436944fd845fa8b50a14855c7a075244b66a..c7f795008a5b920d19b32b39a9a8895c8ea20c0e 100644 (file)
@@ -38,8 +38,8 @@ jest.mock(
 it.each([
   ['error', '1px solid rgb(249,112,102)'],
   ['warning', '1px solid rgb(248,205,92)'],
-  ['success', '1px solid rgb(50,213,131)'],
-  ['info', '1px solid rgb(110,185,228)'],
+  ['success', '1px solid rgb(166,208,91)'],
+  ['info', '1px solid rgb(143,202,234)'],
 ])('should render properly for "%s" variant', (variant: Variant, color) => {
   renderFlagMessage({ variant });
 
diff --git a/server/sonar-web/design-system/src/sonar-aligned/components/__tests__/FlagMessageV2-test.tsx b/server/sonar-web/design-system/src/sonar-aligned/components/__tests__/FlagMessageV2-test.tsx
new file mode 100644 (file)
index 0000000..5b2dfe9
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * 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>,
+  );
+}
index da53330b9a488a1518e5555b01f73ce3f2baeb36..4c7641353c3a58d054fef17731016f72281d9dac 100644 (file)
@@ -21,6 +21,7 @@
 export * from './buttons';
 export * from './Card';
 export { DismissableFlagMessage, FlagMessage } from './FlagMessage';
+export { FlagMessageV2 } from './FlagMessageV2';
 export * from './input';
 export * from './MetricsRatingBadge';
 export * from './Table';
index 0a136abeec49946ad4238480c2e5da40f60ea029..cd17f9674f5736e38cc355ee9bd9a2488ce2ac03 100644 (file)
@@ -186,22 +186,52 @@ export const lightTheme = {
 
     // 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,