]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19245 Implement selection cards in the new component library
author7PH <benjamin.raymond@sonarsource.com>
Tue, 9 May 2023 14:12:28 +0000 (16:12 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 10 May 2023 20:05:28 +0000 (20:05 +0000)
server/sonar-web/design-system/src/components/SelectionCard.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/SelectionCard-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/icons/RecommendedIcon.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/theme/light.ts

diff --git a/server/sonar-web/design-system/src/components/SelectionCard.tsx b/server/sonar-web/design-system/src/components/SelectionCard.tsx
new file mode 100644 (file)
index 0000000..9510205
--- /dev/null
@@ -0,0 +1,171 @@
+/*
+ * 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 classNames from 'classnames';
+import { noop } from 'lodash';
+import tw from 'twin.macro';
+import { translate } from '../helpers/l10n';
+import { themeBorder, themeColor, themeContrast, themeShadow } from '../helpers/theme';
+import RadioButton from './RadioButton';
+import { LightLabel } from './Text';
+import { RecommendedIcon } from './icons/RecommendedIcon';
+
+export interface SelectionCardProps {
+  children?: React.ReactNode;
+  className?: string;
+  disabled?: boolean;
+  onClick?: VoidFunction;
+  recommended?: boolean;
+  recommendedReason?: string;
+  selected?: boolean;
+  title: string;
+  titleInfo?: React.ReactNode;
+  vertical?: boolean;
+}
+
+export function SelectionCard(props: SelectionCardProps) {
+  const {
+    children,
+    className,
+    disabled,
+    onClick,
+    recommended,
+    recommendedReason,
+    selected = false,
+    title,
+    titleInfo,
+    vertical = false,
+  } = props;
+  const isActionable = Boolean(onClick);
+  return (
+    <Wrapper
+      className={classNames(
+        'js-radio-card',
+        {
+          'card-actionable': isActionable && !disabled,
+          'card-vertical': vertical,
+          disabled,
+          selected,
+        },
+        className
+      )}
+      onClick={isActionable && !disabled ? onClick : undefined}
+      tabIndex={0}
+    >
+      <Content>
+        {isActionable && (
+          <div className="sw-items-start sw-mt-1/2 sw-mr-2">
+            <RadioButton checked={selected} disabled={disabled} onCheck={noop} value={title} />
+          </div>
+        )}
+        <div>
+          <Header>
+            {title}
+            <LightLabel>{titleInfo}</LightLabel>
+          </Header>
+          <Body>{children}</Body>
+        </div>
+      </Content>
+      {recommended && (
+        <Recommended>
+          <StyledRecommendedIcon className="sw-mr-1" />
+          <span className="sw-align-middle">
+            <strong>{translate('recommended')}</strong> {recommendedReason}
+          </span>
+        </Recommended>
+      )}
+    </Wrapper>
+  );
+}
+
+const Wrapper = styled.div`
+  ${tw`sw-relative sw-flex sw-flex-col`}
+  ${tw`sw-rounded-2`}
+  ${tw`sw-box-border`}
+
+  background-color: ${themeColor('backgroundSecondary')};
+  border: ${themeBorder('default', 'selectionCardBorder')};
+
+  &:focus {
+    outline: none;
+  }
+
+  &.card-vertical {
+    ${tw`sw-w-full`}
+    min-height: auto;
+  }
+
+  &.card-actionable {
+    ${tw`sw-cursor-pointer`}
+
+    &:hover {
+      border: ${themeBorder('default', 'selectionCardBorderHover')};
+      box-shadow: ${themeShadow('sm')};
+    }
+
+    &.selected {
+      border: ${themeBorder('default', 'selectionCardBorderSelected')};
+    }
+  }
+
+  &.disabled {
+    ${tw`sw-cursor-not-allowed`}
+
+    background-color: ${themeColor('selectionCardDisabled')};
+    border: ${themeBorder('default', 'selectionCardBorderDisabled')};
+  }
+`;
+
+const Content = styled.div`
+  ${tw`sw-my-4 sw-mx-3`}
+  ${tw`sw-flex sw-grow`}
+`;
+
+const Recommended = styled.div`
+  ${tw`sw-body-sm`}
+  ${tw`sw-py-2 sw-px-4`}
+  ${tw`sw-box-border`}
+  ${tw`sw-rounded-b-2`}
+
+  color: ${themeContrast('infoBackground')};
+  background-color: ${themeColor('infoBackground')};
+`;
+
+const StyledRecommendedIcon = styled(RecommendedIcon)`
+  color: ${themeColor('iconInfo')};
+  ${tw`sw-align-middle`}
+`;
+
+const Header = styled.h2`
+  ${tw`sw-flex sw-items-center`}
+  ${tw`sw-mb-3 sw-gap-2`}
+  ${tw`sw-body-sm-highlight`}
+
+  color: ${themeColor('selectionCardHeader')};
+
+  .disabled & {
+    color: ${themeContrast('selectionCardDisabled')};
+  }
+`;
+
+const Body = styled.div`
+  ${tw`sw-flex sw-grow`}
+  ${tw`sw-flex-col sw-justify-between`}
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx b/server/sonar-web/design-system/src/components/__tests__/RadioButton-test.tsx
new file mode 100644 (file)
index 0000000..0da90cc
--- /dev/null
@@ -0,0 +1,62 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import RadioButton from '../RadioButton';
+
+const value = 'value';
+
+it('should render properly', () => {
+  setupWithProps();
+  expect(screen.getByRole('radio')).not.toBeChecked();
+});
+
+it('should render properly when checked', () => {
+  setupWithProps({ checked: true });
+  expect(screen.getByRole('radio')).toBeChecked();
+});
+
+it('should invoke callback on click', async () => {
+  const user = userEvent.setup();
+  const onCheck = jest.fn();
+  setupWithProps({ onCheck, value });
+
+  await user.click(screen.getByRole('radio'));
+  expect(onCheck).toHaveBeenCalled();
+});
+
+it('should not invoke callback on click when disabled', async () => {
+  const user = userEvent.setup();
+  const onCheck = jest.fn();
+  setupWithProps({ disabled: true, onCheck });
+
+  await user.click(screen.getByRole('radio'));
+  expect(onCheck).not.toHaveBeenCalled();
+});
+
+function setupWithProps(props?: Partial<FCProps<typeof RadioButton>>) {
+  return render(
+    <RadioButton checked={false} onCheck={jest.fn()} value="value" {...props}>
+      foo
+    </RadioButton>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/SelectionCard-test.tsx b/server/sonar-web/design-system/src/components/__tests__/SelectionCard-test.tsx
new file mode 100644 (file)
index 0000000..ef37745
--- /dev/null
@@ -0,0 +1,79 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { SelectionCard } from '../SelectionCard';
+
+it('should render option and be actionnable', async () => {
+  const user = userEvent.setup();
+  const onClick = jest.fn();
+  setupWithProps({
+    onClick,
+    children: <>The Option</>,
+  });
+
+  // Click on content
+  await user.click(screen.getByText(/The Option/));
+  expect(onClick).toHaveBeenCalledTimes(1);
+
+  // Click on radio button
+  await user.click(screen.getByRole('radio'));
+  expect(onClick).toHaveBeenCalledTimes(2);
+});
+
+it('should not be actionnable when disabled', async () => {
+  const user = userEvent.setup();
+  const onClick = jest.fn();
+  setupWithProps({
+    onClick,
+    disabled: true,
+    children: <>The Option</>,
+  });
+
+  // Clicking on content or radio button should not trigger click handler
+  await user.click(screen.getByText(/The Option/));
+  expect(onClick).not.toHaveBeenCalled();
+
+  await user.click(screen.getByRole('radio'));
+  expect(onClick).not.toHaveBeenCalled();
+});
+
+it('should not be actionnable when no click handler', () => {
+  setupWithProps({
+    children: <>The Option</>,
+  });
+
+  // Radio button should not be shown
+  expect(screen.queryByRole('radio')).not.toBeInTheDocument();
+});
+
+function setupWithProps(props: Partial<FCProps<typeof SelectionCard>> = {}) {
+  return render(
+    <SelectionCard
+      recommended={true}
+      recommendedReason="Recommended for you"
+      title="Selection Card"
+      titleInfo="info"
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/icons/RecommendedIcon.tsx b/server/sonar-web/design-system/src/components/icons/RecommendedIcon.tsx
new file mode 100644 (file)
index 0000000..b77fc55
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * 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 { VerifiedIcon } from '@primer/octicons-react';
+import { OcticonHoc } from './Icon';
+
+export const RecommendedIcon = OcticonHoc(VerifiedIcon, 'RecommendedIcon');
index c5e19041f0ebae45cd7126433f9e0d7683944eae..553212a1502fcdf195f3a83f4315f779385861d8 100644 (file)
@@ -46,6 +46,7 @@ export * from './MetricsRatingBadge';
 export * from './NavBarTabs';
 export * from './NewCodeLegend';
 export { QualityGateIndicator } from './QualityGateIndicator';
+export * from './SelectionCard';
 export * from './Separator';
 export * from './SizeIndicator';
 export * from './SonarQubeLogo';
index e57ff1c27209c82ac0c0b853a1c427b0aa0734ba..3683139b845b040b9bd98509da2d5788d2aca1ab 100644 (file)
@@ -493,6 +493,9 @@ export const lightTheme = {
     // flag message
     flagMessageBackground: secondary.darker,
 
+    // info message
+    infoBackground: COLORS.blue[900],
+
     // banner message
     bannerMessage: COLORS.red[900],