*/
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 {
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>
);
}
}
`;
-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`}
--- /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 { 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`};
+`;
--- /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 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} />);
+}
export * from './SpotlightTour';
export * from './Switch';
export * from './Table';
+export * from './Tabs';
export * from './Tags';
export * from './Text';
export * from './TextAccordion';
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],
// 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],
badgeDefault: COLORS.blueGrey[700],
badgeDeleted: COLORS.red[900],
badgeCounter: secondary.darker,
+ badgeCounterFailed: danger.dark,
// pills
pillDanger: COLORS.red[800],
*/
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';
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">
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';
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;
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"
} 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';
import MeasuresCardPercent from '../components/MeasuresCardPercent';
import {
MeasurementType,
+ MeasuresTabs,
Status,
getConditionRequiredLabel,
getMeasurementMetricKey,
}
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"
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';
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) {
</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">
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);
});