]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21467 Implement new Tabs component
authorstanislavh <stanislav.honcharov@sonarsource.com>
Fri, 26 Jan 2024 12:54:12 +0000 (13:54 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 29 Jan 2024 20:03:16 +0000 (20:03 +0000)
server/sonar-web/design-system/src/components/Badge.tsx
server/sonar-web/design-system/src/components/Tabs.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/theme/light.ts
server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanel.tsx
server/sonar-web/src/main/js/apps/overview/branches/MeasuresPanelNoNewCode.tsx
server/sonar-web/src/main/js/apps/overview/branches/NewCodeMeasuresPanel.tsx
server/sonar-web/src/main/js/apps/overview/branches/TabsPanel.tsx
server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx

index 4d8ea88fed39ba1c3b349cbc93ecc861cc300dee..b1cc762001e7216afe016c3be945d2cd7fb5beeb 100644 (file)
  */
 import styled from '@emotion/styled';
 import tw from 'twin.macro';
-import { themeColor, themeContrast } from '../helpers/theme';
+import { themeBorder, themeColor, themeContrast } from '../helpers/theme';
 import { ThemeColors } from '../types/theme';
 
-type BadgeVariant = 'default' | 'new' | 'deleted' | 'counter';
+type BadgeVariant = 'default' | 'new' | 'deleted' | 'counter' | 'counterFailed';
 
 const variantList: Record<BadgeVariant, ThemeColors> = {
   default: 'badgeDefault',
   new: 'badgeNew',
   deleted: 'badgeDeleted',
   counter: 'badgeCounter',
+  counterFailed: 'badgeCounterFailed',
 };
 
 interface BadgeProps {
@@ -45,13 +46,13 @@ export function Badge({ className, children, title, variant = 'default' }: Badge
     role: 'status',
     title,
   };
-  if (variant === 'counter') {
-    return <StyledCounter {...commonProps}>{children}</StyledCounter>;
-  }
+
+  const Component = ['counter', 'counterFailed'].includes(variant) ? StyledCounter : StyledBadge;
+
   return (
-    <StyledBadge variantInfo={variantList[variant]} {...commonProps}>
+    <Component variantInfo={variantList[variant]} {...commonProps}>
       {children}
-    </StyledBadge>
+    </Component>
   );
 }
 
@@ -80,17 +81,25 @@ const StyledBadge = styled.span<{
   }
 `;
 
-const StyledCounter = styled.span`
+const StyledCounter = styled.span<{
+  variantInfo: ThemeColors;
+}>`
+  ${tw`sw-min-w-5 sw-min-h-5`};
   ${tw`sw-text-[0.75rem]`};
   ${tw`sw-font-regular`};
-  ${tw`sw-px-2`};
+  ${tw`sw-box-border sw-px-[5px]`};
   ${tw`sw-inline-flex`};
   ${tw`sw-leading-[1.125rem]`};
   ${tw`sw-items-center sw-justify-center`};
   ${tw`sw-rounded-pill`};
 
-  color: ${themeContrast('badgeCounter')};
-  background-color: ${themeColor('badgeCounter')};
+  color: ${({ variantInfo }) => themeContrast(variantInfo)};
+  background-color: ${({ variantInfo }) => themeColor(variantInfo)};
+  border: ${({ variantInfo }) =>
+    themeBorder(
+      'default',
+      variantInfo === 'badgeCounterFailed' ? 'badgeCounterFailedBorder' : 'transparent',
+    )};
 
   &:empty {
     ${tw`sw-hidden`}
diff --git a/server/sonar-web/design-system/src/components/Tabs.tsx b/server/sonar-web/design-system/src/components/Tabs.tsx
new file mode 100644 (file)
index 0000000..dd0381f
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * 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 { PropsWithChildren } from 'react';
+import tw from 'twin.macro';
+import { OPACITY_20_PERCENT, themeBorder, themeColor } from '../helpers';
+import { getTabId, getTabPanelId } from '../helpers/tabs';
+import { Badge } from './Badge';
+import { BareButton } from './buttons';
+
+type TabValueType = string | number | boolean;
+
+export interface TabOption<T extends TabValueType> {
+  counter?: number;
+  disabled?: boolean;
+  label: string | React.ReactNode;
+  value: T;
+}
+
+export interface TabsProps<T extends TabValueType> {
+  className?: string;
+  disabled?: boolean;
+  label?: string;
+  onChange: (value: T) => void;
+  options: ReadonlyArray<TabOption<T>>;
+  value?: T;
+}
+
+export function Tabs<T extends TabValueType>(props: PropsWithChildren<TabsProps<T>>) {
+  const { disabled = false, label, options, value, className, children } = props;
+
+  return (
+    <TabsContainer className={className}>
+      <TabList aria-label={label} role="tablist">
+        {options.map((option) => (
+          <TabButton
+            aria-controls={getTabPanelId(String(option.value))}
+            aria-current={option.value === value}
+            aria-selected={option.value === value}
+            data-value={option.value}
+            disabled={disabled || option.disabled}
+            id={getTabId(String(option.value))}
+            key={option.value.toString()}
+            onClick={() => {
+              if (option.value !== value) {
+                props.onChange(option.value);
+              }
+            }}
+            role="tab"
+            selected={option.value === value}
+          >
+            {option.label}
+            {option.counter ? (
+              <Badge className="sw-ml-2" variant="counterFailed">
+                {option.counter}
+              </Badge>
+            ) : null}
+          </TabButton>
+        ))}
+      </TabList>
+      <RightSection>{children}</RightSection>
+    </TabsContainer>
+  );
+}
+
+const TabsContainer = styled.div`
+  ${tw`sw-w-full`};
+  ${tw`sw-flex sw-justify-between`};
+  border-bottom: ${themeBorder('default')};
+`;
+
+const TabList = styled.div`
+  ${tw`sw-inline-flex`};
+`;
+
+const TabButton = styled(BareButton)<{ selected: boolean }>`
+  ${tw`sw-relative`};
+  ${tw`sw-px-3 sw-py-1 sw-mb-[-1px]`};
+  ${tw`sw-flex sw-items-center`};
+  ${tw`sw-body-sm`};
+  ${tw`sw-font-semibold`};
+  ${tw`sw-rounded-t-1`};
+
+  height: 34px;
+  background: ${(props) => (props.selected ? themeColor('backgroundSecondary') : 'none')};
+  color: ${(props) => (props.selected ? themeColor('tabSelected') : themeColor('tab'))};
+  border: ${(props) =>
+    props.selected ? themeBorder('default') : themeBorder('default', 'transparent')};
+  border-bottom: ${(props) =>
+    themeBorder('default', props.selected ? 'backgroundSecondary' : undefined)};
+
+  &:hover {
+    background: ${themeColor('tabHover')};
+  }
+
+  &:focus,
+  &:active {
+    outline: ${themeBorder('xsActive', 'tabSelected', OPACITY_20_PERCENT)};
+    z-index: 1;
+  }
+
+  // Active line
+  &::after {
+    content: '';
+    ${tw`sw-absolute`};
+    ${tw`sw-rounded-t-1`};
+    top: 0;
+    left: 0;
+    width: calc(100%);
+    height: 2px;
+    background: ${(props) => (props.selected ? themeColor('tabSelected') : 'none')};
+  }
+`;
+
+const RightSection = styled.div`
+  max-height: 43px;
+  ${tw`sw-flex sw-items-center`};
+`;
diff --git a/server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tabs-test.tsx
new file mode 100644 (file)
index 0000000..5b6eb4c
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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 userEvent from '@testing-library/user-event';
+import { render } from '../../helpers/testUtils';
+import { FCProps } from '../../types/misc';
+import { TabOption, Tabs } from '../Tabs';
+
+it('should render all options', async () => {
+  const user = userEvent.setup();
+  const onChange = jest.fn();
+  const options: Array<TabOption<number>> = [
+    { value: 1, label: 'first' },
+    { value: 2, label: 'disabled', disabled: true },
+    { value: 3, label: 'has counter', counter: 7 },
+  ];
+  renderToggleButtons({ onChange, options, value: 1 });
+
+  expect(screen.getAllByRole('tab')).toHaveLength(3);
+
+  await user.click(screen.getByText('first'));
+
+  expect(onChange).not.toHaveBeenCalled();
+
+  await user.click(screen.getByText('has counter'));
+
+  expect(onChange).toHaveBeenCalledWith(3);
+});
+
+function renderToggleButtons(props: Partial<FCProps<typeof Tabs>> = {}) {
+  return render(<Tabs onChange={jest.fn()} options={[]} {...props} />);
+}
index 92661b36e237bddb1e2bb7c174885286e096da17..c23b99a2d68b197f22cf27462f49d7ba3dc4b8d3 100644 (file)
@@ -73,6 +73,7 @@ export { Spinner } from './Spinner';
 export * from './SpotlightTour';
 export * from './Switch';
 export * from './Table';
+export * from './Tabs';
 export * from './Tags';
 export * from './Text';
 export * from './TextAccordion';
index 35ec15d93bbe2ac3e44c20a3d5a649dcbc237682..1eb37c7c559c91080c745b0400997d803ff04101 100644 (file)
@@ -305,6 +305,8 @@ export const lightTheme = {
     badgeDefault: COLORS.blueGrey[100],
     badgeDeleted: COLORS.red[100],
     badgeCounter: COLORS.blueGrey[100],
+    badgeCounterFailed: COLORS.red[50],
+    badgeCounterFailedBorder: COLORS.red[200],
 
     // pills
     pillDanger: COLORS.red[50],
@@ -325,6 +327,11 @@ export const lightTheme = {
     // tab
     tabBorder: primary.light,
 
+    // tabs
+    tab: COLORS.blueGrey[400],
+    tabSelected: primary.default,
+    tabHover: COLORS.blueGrey[25],
+
     //table
     tableRowHover: COLORS.indigo[25],
     tableRowSelected: COLORS.indigo[300],
@@ -709,6 +716,7 @@ export const lightTheme = {
     badgeDefault: COLORS.blueGrey[700],
     badgeDeleted: COLORS.red[900],
     badgeCounter: secondary.darker,
+    badgeCounterFailed: danger.dark,
 
     // pills
     pillDanger: COLORS.red[800],
index 1f73d4099e031b33ad02290bc455e760890a7668..6c6b6c541e03ba1c4db414edff050e7c7c908723 100644 (file)
  */
 import { Card, CoverageIndicator, DuplicationsIndicator } from 'design-system';
 import * as React from 'react';
+import { getTabPanelId } from '../../../components/controls/BoxedTabs';
 import { duplicationRatingConverter } from '../../../components/measure/utils';
 import { findMeasure } from '../../../helpers/measures';
 import { Branch } from '../../../types/branch-like';
 import { IssueType } from '../../../types/issues';
 import { MetricKey } from '../../../types/metrics';
 import { Component, MeasureEnhanced } from '../../../types/types';
-import { MeasurementType } from '../utils';
+import { MeasurementType, MeasuresTabs } from '../utils';
 import MeasuresPanelIssueMeasure from './MeasuresPanelIssueMeasure';
 import MeasuresPanelPercentMeasure from './MeasuresPanelPercentMeasure';
 
@@ -40,7 +41,10 @@ export function MeasuresPanel(props: MeasuresPanelProps) {
   const { branch, component, measures, isNewCode } = props;
 
   return (
-    <div className="sw-grid sw-grid-cols-2 sw-gap-4 sw-mt-4">
+    <div
+      className="sw-grid sw-grid-cols-2 sw-gap-4 sw-mt-6"
+      id={getTabPanelId(MeasuresTabs.Overall)}
+    >
       {[IssueType.Bug, IssueType.CodeSmell, IssueType.Vulnerability, IssueType.SecurityHotspot].map(
         (type: IssueType) => (
           <Card key={type} className="sw-p-8">
index fad2a62c4d240d8f1547ac1d298e23394a206d02..b2bea1237ad9ba16d1cadf4002a4a10a2de3de7b 100644 (file)
@@ -21,6 +21,7 @@ import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import DocLink from '../../../components/common/DocLink';
 import Link from '../../../components/common/Link';
+import { getTabPanelId } from '../../../components/controls/BoxedTabs';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { getBaseUrl } from '../../../helpers/system';
@@ -29,6 +30,7 @@ import { Branch } from '../../../types/branch-like';
 import { ComponentQualifier } from '../../../types/component';
 import { NewCodeDefinitionType } from '../../../types/new-code-definition';
 import { Component, Period } from '../../../types/types';
+import { MeasuresTabs } from '../utils';
 
 export interface MeasuresPanelNoNewCodeProps {
   branch?: Branch;
@@ -60,7 +62,11 @@ export default function MeasuresPanelNoNewCode(props: MeasuresPanelNoNewCodeProp
   const showSettingsLink = !!(component.configuration && component.configuration.showSettings);
 
   return (
-    <div className="display-flex-center display-flex-justify-center" style={{ height: 500 }}>
+    <div
+      className="display-flex-center display-flex-justify-center"
+      id={getTabPanelId(MeasuresTabs.New)}
+      style={{ height: 500 }}
+    >
       <img
         alt="" /* Make screen readers ignore this image; it's purely eye candy. */
         className="spacer-right"
index a579166992b102105627e05d4a565b2144247d5b..f4eb31379e4ffbf216068e38f7f17388545f3933 100644 (file)
@@ -29,6 +29,7 @@ import {
 } from 'design-system';
 import React from 'react';
 import { useIntl } from 'react-intl';
+import { getTabPanelId } from '../../../components/controls/BoxedTabs';
 import { getLeakValue } from '../../../components/measure/utils';
 import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
@@ -49,6 +50,7 @@ import MeasuresCardNumber from '../components/MeasuresCardNumber';
 import MeasuresCardPercent from '../components/MeasuresCardPercent';
 import {
   MeasurementType,
+  MeasuresTabs,
   Status,
   getConditionRequiredLabel,
   getMeasurementMetricKey,
@@ -91,7 +93,7 @@ export default function NewCodeMeasuresPanel(props: Readonly<Props>) {
   }
 
   return (
-    <div className="sw-mt-6">
+    <div className="sw-mt-6" id={getTabPanelId(MeasuresTabs.New)}>
       <LightGreyCard className="sw-flex sw-rounded-2 sw-gap-4">
         <IssueMeasuresCardInner
           data-test="overview__measures-new_issues"
index 8b000523a8d15461d89a3c4de2ec2baa62daadc6..5760185f04c4fb8623b59f6b3baf7e9870b461a0 100644 (file)
@@ -25,10 +25,10 @@ import {
   LightLabel,
   PageTitle,
   Spinner,
-  ToggleButton,
+  Tabs,
 } from 'design-system';
 import * as React from 'react';
-import { FormattedMessage, useIntl } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 import DocLink from '../../../components/common/DocLink';
 import { translate } from '../../../helpers/l10n';
 import { isDiffMetric } from '../../../helpers/measures';
@@ -69,13 +69,11 @@ export function TabsPanel(props: React.PropsWithChildren<MeasuresPanelProps>) {
     branch,
     children,
   } = props;
-  const intl = useIntl();
   const isApp = component.qualifier === ComponentQualifier.Application;
   const leakPeriod = isApp ? appLeak : period;
 
   const { failingConditionsOnNewCode, failingConditionsOnOverallCode } =
     countFailingConditions(qgStatuses);
-  const failingConditions = failingConditionsOnNewCode + failingConditionsOnOverallCode;
 
   const recentSqUpgradeEvent = React.useMemo(() => {
     if (!analyses || analyses.length === 0) {
@@ -160,28 +158,19 @@ export function TabsPanel(props: React.PropsWithChildren<MeasuresPanelProps>) {
             </div>
           )}
           <div className="sw-flex sw-items-center">
-            <ToggleButton
+            <Tabs
               onChange={props.onTabSelect}
               options={tabs}
               value={isNewCode ? MeasuresTabs.New : MeasuresTabs.Overall}
-            />
-            {failingConditions > 0 && (
-              <LightLabel className="sw-body-sm-highlight sw-ml-8">
-                {intl.formatMessage(
-                  { id: 'overview.X_conditions_failed' },
-                  { conditions: failingConditions },
-                )}
-              </LightLabel>
-            )}
+            >
+              {isNewCode && leakPeriod && (
+                <LightLabel className="sw-body-sm sw-flex sw-items-center">
+                  <span className="sw-mr-1">{translate('overview.new_code')}:</span>
+                  <LeakPeriodInfo leakPeriod={leakPeriod} />
+                </LightLabel>
+              )}
+            </Tabs>
           </div>
-          {isNewCode && leakPeriod ? (
-            <LightLabel className="sw-body-sm sw-flex sw-items-center sw-mt-4">
-              <span className="sw-mr-1">{translate('overview.new_code')}:</span>
-              <LeakPeriodInfo leakPeriod={leakPeriod} />
-            </LightLabel>
-          ) : (
-            <div className="sw-h-4 sw-pt-1 sw-mt-4" />
-          )}
 
           {component.qualifier === ComponentQualifier.Application && component.needIssueSync && (
             <FlagMessage className="sw-mt-4" variant="info">
index 22b3c562514aacb3956c3a61288332b94c27a018..3f54500b1c012b6ec448e3a80c46cfe8c8f11f1b 100644 (file)
@@ -332,7 +332,7 @@ describe('project overview', () => {
     renderBranchOverview();
 
     expect(await screen.findByText('metric.level.ERROR')).toBeInTheDocument();
-    expect(screen.getAllByText(/overview.X_conditions_failed/)).toHaveLength(2);
+    expect(screen.getByText(/overview.X_conditions_failed/)).toBeInTheDocument();
     expect(screen.getAllByText(/overview.quality_gate.required_x/)).toHaveLength(3);
   });