Browse Source

SONAR-21656 Migrate TreeMap-related components to the new UI

tags/10.5.0.89998
Jeremy Davis 2 months ago
parent
commit
64f9d6afe4

+ 10
- 0
server/sonar-web/design-system/src/theme/light.ts View File

@@ -604,6 +604,16 @@ export const lightTheme = {
'bubble.4': [...COLORS.orange[500], 0.3],
'bubble.5': [...COLORS.red[500], 0.3],

// TreeMap Colors
'treeMap.A': COLORS.green[500],
'treeMap.B': COLORS.yellowGreen[500],
'treeMap.C': COLORS.yellow[500],
'treeMap.D': COLORS.orange[500],
'treeMap.E': COLORS.red[500],
'treeMap.NA1': COLORS.blueGrey[300],
'treeMap.NA2': COLORS.blueGrey[200],
treeMapCellTextColor: COLORS.blueGrey[900],

// new code legend
newCodeLegend: [...COLORS.indigo[300], 0.15],
newCodeLegendBorder: COLORS.indigo[200],

+ 29
- 2
server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx View File

@@ -120,17 +120,44 @@ describe('rendering', () => {
expect(ui.measuresRows.getAll()).toHaveLength(7);
});

it('should correctly render a treemap view', async () => {
it('should correctly render a rating treemap view', async () => {
const { ui } = getPageObject();
renderMeasuresApp('component_measures?id=foo&metric=sqale_rating&view=treemap');
await ui.appLoaded();

expect(within(ui.treeMap.get()).getAllByRole('link')).toHaveLength(7);
expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7);
expect(ui.treeMapCell(/folderA .+ Maintainability Rating: C/).get()).toBeInTheDocument();
expect(ui.treeMapCell(/test1\.js .+ Maintainability Rating: B/).get()).toBeInTheDocument();
expect(ui.treeMapCell(/index\.tsx .+ Maintainability Rating: A/).get()).toBeInTheDocument();
});

it('should correctly render a percent treemap view', async () => {
const { measures } = componentsHandler;

measures['foo:folderA'][MetricKey.coverage] = {
metric: MetricKey.coverage,
value: '74.2',
};
measures['foo:test1.js'][MetricKey.coverage] = {
metric: MetricKey.coverage,
value: undefined,
};
measures['foo:index.tsx'][MetricKey.coverage] = {
metric: MetricKey.coverage,
value: '13.1',
};

const { ui } = getPageObject();
renderMeasuresApp('component_measures?id=foo&metric=coverage&view=treemap');
await ui.appLoaded();

expect(ui.treeMap.byRole('link').getAll()).toHaveLength(7);

expect(ui.treeMapCell(/folderA .+ Coverage: 74.2%/).get()).toBeInTheDocument();
expect(ui.treeMapCell(/test1\.js .+ Coverage: —/).get()).toBeInTheDocument();
expect(ui.treeMapCell(/index\.tsx .+ Coverage: 13.1%/).get()).toBeInTheDocument();
});

it('should render correctly for an unknown metric', async () => {
const { ui } = getPageObject();
renderMeasuresApp('component_measures?id=foo&metric=unknown');

+ 2
- 3
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx View File

@@ -19,11 +19,11 @@
*/
import { withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Spinner } from '@sonarsource/echoes-react';
import {
LargeCenteredLayout,
Note,
PageContentFontWrapper,
Spinner,
themeBorder,
themeColor,
} from 'design-system';
@@ -45,7 +45,6 @@ import { MeasurePageView } from '../../../types/measures';
import { MetricKey } from '../../../types/metrics';
import { ComponentMeasure, Dict, MeasureEnhanced, Metric, Period } from '../../../types/types';
import Sidebar from '../sidebar/Sidebar';
import '../style.css';
import {
Query,
banQualityGateMeasure,
@@ -281,7 +280,7 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> {
<Suggestions suggestions="component_measures" />
<Helmet defer={false} title={translate('layout.measures')} />
<PageContentFontWrapper className="sw-body-sm">
<Spinner className="my-10 sw-flex sw-content-center" loading={this.state.loading} />
<Spinner className="my-10 sw-flex sw-content-center" isLoading={this.state.loading} />

{measures.length > 0 ? (
<div className="sw-grid sw-grid-cols-12 sw-w-full">

+ 1
- 1
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx View File

@@ -430,7 +430,7 @@ export default class MeasureContent extends React.PureComponent<Props, State> {
secondaryMeasure={secondaryMeasure}
/>
{isFileComponent ? (
<div className="measure-details-viewer">
<div>
<SourceViewer
hideHeader
branchLike={branchLike}

+ 2
- 1
server/sonar-web/src/main/js/apps/component-measures/drilldown/EmptyResult.tsx View File

@@ -17,9 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Note } from 'design-system';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';

export default function EmptyResult() {
return <div className="note">{translate('no_results')}</div>;
return <Note>{translate('no_results')}</Note>;
}

+ 64
- 36
server/sonar-web/src/main/js/apps/component-measures/drilldown/TreeMapView.tsx View File

@@ -18,15 +18,23 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { scaleLinear, scaleOrdinal } from 'd3-scale';
import { TreeMap, TreeMapItem } from 'design-system';
import {
CSSColor,
Note,
QualifierIcon,
ThemeColors,
ThemeProp,
TreeMap,
TreeMapItem,
themeColor,
withTheme,
} from 'design-system';
import { isEmpty } from 'lodash';
import * as React from 'react';
import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer';
import { colors } from '../../../app/theme';
import ColorBoxLegend from '../../../components/charts/ColorBoxLegend';
import ColorGradientLegend from '../../../components/charts/ColorGradientLegend';
import QualifierIcon from '../../../components/icons/QualifierIcon';
import { getComponentMeasureUniqueKey } from '../../../helpers/component';
import { RATING_COLORS } from '../../../helpers/constants';
import { getLocalizedMetricName, translate } from '../../../helpers/l10n';
import { formatMeasure, isDiffMetric } from '../../../helpers/measures';
import { isDefined } from '../../../helpers/types';
@@ -34,27 +42,32 @@ import { MetricKey, MetricType } from '../../../types/metrics';
import { ComponentMeasureEnhanced, ComponentMeasureIntern, Metric } from '../../../types/types';
import EmptyResult from './EmptyResult';

interface Props {
interface TreeMapViewProps {
components: ComponentMeasureEnhanced[];
handleSelect: (component: ComponentMeasureIntern) => void;
metric: Metric;
}

type Props = TreeMapViewProps & ThemeProp;

interface State {
treemapItems: Array<TreeMapItem<ComponentMeasureIntern>>;
}

const PERCENT_SCALE_DOMAIN = [0, 25, 50, 75, 100];
const RATING_SCALE_DOMAIN = [1, 2, 3, 4, 5];

const HEIGHT = 500;
const COLORS = RATING_COLORS.map(({ fill }) => fill);
const LEVEL_COLORS = [
colors.error500,
colors.orange,
colors.success500,
colors.disabledQualityGate,
const NA_COLORS: [ThemeColors, ThemeColors] = ['treeMap.NA1', 'treeMap.NA2'];
const TREEMAP_COLORS: ThemeColors[] = [
'treeMap.A',
'treeMap.B',
'treeMap.C',
'treeMap.D',
'treeMap.E',
];
const NA_GRADIENT = `linear-gradient(-45deg, ${colors.gray71} 25%, ${colors.gray60} 25%, ${colors.gray60} 50%, ${colors.gray71} 50%, ${colors.gray71} 75%, ${colors.gray60} 75%, ${colors.gray60} 100%)`;

export default class TreeMapView extends React.PureComponent<Props, State> {
export class TreeMapView extends React.PureComponent<Props, State> {
state: State;

constructor(props: Props) {
@@ -98,9 +111,9 @@ export default class TreeMapView extends React.PureComponent<Props, State> {

return {
key: getComponentMeasureUniqueKey(component) ?? '',
color: colorValue ? (colorScale as Function)(colorValue) : undefined,
gradient: !colorValue ? NA_GRADIENT : undefined,
icon: <QualifierIcon fill={colors.baseFontColor} qualifier={component.qualifier} />,
color: isDefined(colorValue) ? (colorScale as Function)(colorValue) : undefined,
gradient: !isDefined(colorValue) ? this.getNAGradient() : undefined,
icon: <QualifierIcon fill="pageContent" qualifier={component.qualifier} />,
label: [component.name, component.branch].filter((s) => !!s).join(' / '),
size: sizeValue,
measureValue: colorValue,
@@ -118,16 +131,35 @@ export default class TreeMapView extends React.PureComponent<Props, State> {
.filter(isDefined);
};

getNAGradient = () => {
const { theme } = this.props;
const [shade1, shade2] = NA_COLORS.map((c) => themeColor(c)({ theme }));

return `linear-gradient(-45deg, ${shade1} 25%, ${shade2} 25%, ${shade2} 50%, ${shade1} 50%, ${shade1} 75%, ${shade2} 75%, ${shade2} 100%)`;
};

getMappedThemeColors = (): string[] => {
const { theme } = this.props;
return TREEMAP_COLORS.map((c) => themeColor(c)({ theme }));
};

getLevelColorScale = () =>
scaleOrdinal<string, string>().domain(['ERROR', 'WARN', 'OK', 'NONE']).range(LEVEL_COLORS);
scaleOrdinal<string, string>()
.domain(['ERROR', 'WARN', 'OK', 'NONE'])
.range(this.getMappedThemeColors());

getPercentColorScale = (metric: Metric) => {
const color = scaleLinear<string, string>().domain([0, 25, 50, 75, 100]);
color.range(metric.higherValuesAreBetter ? [...COLORS].reverse() : COLORS);
const color = scaleLinear<string, string>().domain(PERCENT_SCALE_DOMAIN);
color.range(
metric.higherValuesAreBetter
? [...this.getMappedThemeColors()].reverse()
: this.getMappedThemeColors(),
);
return color;
};

getRatingColorScale = () => scaleLinear<string, string>().domain([1, 2, 3, 4, 5]).range(COLORS);
getRatingColorScale = () =>
scaleLinear<string, string>().domain(RATING_SCALE_DOMAIN).range(this.getMappedThemeColors());

getColorScale = (metric: Metric) => {
if (metric.type === MetricType.Level) {
@@ -155,8 +187,8 @@ export default class TreeMapView extends React.PureComponent<Props, State> {
const formatted =
colorMetric && colorValue !== undefined ? formatMeasure(colorValue, colorMetric.type) : '—';
return (
<div className="text-left">
{[component.name, component.branch].filter((s) => !!s).join(' / ')}
<div className="sw-text-left">
{[component.name, component.branch].filter((s) => !isEmpty(s)).join(' / ')}
<br />
{`${getLocalizedMetricName(sizeMetric)}: ${formatMeasure(sizeValue, sizeMetric.type)}`}
<br />
@@ -172,22 +204,16 @@ export default class TreeMapView extends React.PureComponent<Props, State> {
}

renderLegend() {
const { metric } = this.props;
const { metric, theme } = this.props;
const colorScale = this.getColorScale(metric);
if ([MetricType.Level, MetricType.Rating].includes(metric.type as MetricType)) {
return (
<ColorBoxLegend
className="measure-details-treemap-legend color-box-full"
colorScale={colorScale}
metricType={metric.type}
/>
);
return <ColorBoxLegend colorScale={colorScale} metricType={metric.type} />;
}
return (
<ColorGradientLegend
className="measure-details-treemap-legend"
showColorNA
colorScale={colorScale}
naColors={NA_COLORS.map((c) => themeColor(c)({ theme })) as [CSSColor, CSSColor]}
height={30}
width={200}
/>
@@ -205,22 +231,22 @@ export default class TreeMapView extends React.PureComponent<Props, State> {
? components[0].measures.find((measure) => measure.metric.key !== metric.key)
: null;
return (
<div className="measure-details-treemap" data-testid="treemap">
<div className="display-flex-start note spacer-bottom">
<div data-testid="treemap">
<Note as="div" className="sw-flex sw-items-start sw-mb-2">
<span>
<strong className="sw-mr-1">{translate('component_measures.legend.color')}</strong>
{getLocalizedMetricName(metric)}
</span>
<span className="spacer-left flex-1">
<span className="sw-ml-2 sw-flex-1">
<strong className="sw-mr-1">{translate('component_measures.legend.size')}</strong>
{translate(
'metric',
sizeMeasure && sizeMeasure.metric ? sizeMeasure.metric.key : MetricKey.ncloc,
sizeMeasure?.metric ? sizeMeasure.metric.key : MetricKey.ncloc,
'name',
)}
</span>
<span>{this.renderLegend()}</span>
</div>
</Note>
<AutoSizer disableHeight>
{({ width }) => (
<TreeMap<ComponentMeasureIntern>
@@ -235,3 +261,5 @@ export default class TreeMapView extends React.PureComponent<Props, State> {
);
}
}

export default withTheme(TreeMapView);

+ 0
- 38
server/sonar-web/src/main/js/apps/component-measures/style.css View File

@@ -1,38 +0,0 @@
/*
* 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.
*/
button.search-navigator-facet {
text-align: start;
}

.measure-details-treemap-legend.color-box-legend {
margin-right: 0;
}

.measure-details-viewer .issue-list {
/* no math, just a good guess */
min-width: 600px;
width: 800px;
}

@media (max-width: 1320px) {
.measure-details-viewer .issue-list {
width: calc(60vw - 80px);
}
}

+ 0
- 70
server/sonar-web/src/main/js/components/charts/ColorBoxLegend.css View File

@@ -1,70 +0,0 @@
/*
* 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.
*/
.color-box-legend {
display: flex;
justify-content: center;
}

.color-box-legend .link-checkbox .color-box-legend-rating {
width: 20px;
height: 20px;
line-height: 20px;
display: inline-block;
border: 1px solid transparent;
border-radius: 20px;
color: var(--blacka87);
}

.color-box-legend .link-checkbox[aria-checked='false'] .color-box-legend-rating {
background-color: transparent !important;
border-color: transparent !important;
}

.color-box-legend > *:not(:first-child) {
margin-left: 24px;
}

.color-box-legend .color-box-legend-rect {
display: inline-block;
margin-top: 1px;
margin-right: 4px;
border: 1px solid;
}

.color-box-legend .color-box-legend-rect-inner {
display: block;
width: 8px;
height: 8px;
opacity: 0.2;
}

.color-box-legend.color-box-full .color-box-legend-rect-inner {
opacity: 1;
}

.color-box-legend a {
color: var(--baseFontColor);
border-bottom: none;
display: block;
}

.color-box-legend a.filtered {
opacity: 0.3;
}

+ 22
- 14
server/sonar-web/src/main/js/components/charts/ColorBoxLegend.tsx View File

@@ -17,14 +17,12 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import styled from '@emotion/styled';
import { ScaleLinear, ScaleOrdinal } from 'd3-scale';
import * as React from 'react';
import { formatMeasure } from '../../helpers/measures';
import './ColorBoxLegend.css';

interface Props {
className?: string;
colorNA?: string;
colorScale:
| ScaleOrdinal<string, string> // used for LEVEL type
@@ -32,30 +30,40 @@ interface Props {
metricType: string;
}

export default function ColorBoxLegend({ className, colorScale, colorNA, metricType }: Props) {
export default function ColorBoxLegend({ colorScale, colorNA, metricType }: Props) {
const colorDomain: Array<number | string> = colorScale.domain();
const colorRange = colorScale.range();
return (
<div className={classNames('color-box-legend', className)}>
<div className="sw-flex sw-justify-center sw-gap-6">
{colorDomain.map((value, idx) => (
<div key={value}>
<span className="color-box-legend-rect" style={{ borderColor: colorRange[idx] }}>
<span
className="color-box-legend-rect-inner"
style={{ backgroundColor: colorRange[idx] }}
/>
</span>
<LegendRect style={{ borderColor: colorRange[idx] }}>
<span style={{ backgroundColor: colorRange[idx] }} />
</LegendRect>
{formatMeasure(value, metricType)}
</div>
))}
{colorNA && (
<div>
<span className="color-box-legend-rect" style={{ borderColor: colorNA }}>
<span className="color-box-legend-rect-inner" style={{ backgroundColor: colorNA }} />
</span>
<LegendRect style={{ borderColor: colorNA }}>
<span style={{ backgroundColor: colorNA }} />
</LegendRect>
N/A
</div>
)}
</div>
);
}

const LegendRect = styled.span`
display: inline-block;
margin-top: 1px;
margin-right: 4px;
border: 1px solid;

& span {
display: block;
width: 8px;
height: 8px;
}
`;

+ 0
- 33
server/sonar-web/src/main/js/components/charts/ColorGradientLegend.css View File

@@ -1,33 +0,0 @@
/*
* 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.
*/
.gradient-legend-text,
.gradient-legend-na {
text-anchor: middle;
fill: var(--secondFontColor);
font-size: 10px;
}

.gradient-legend-text:first-of-type {
text-anchor: start;
}

.gradient-legend-text:last-of-type {
text-anchor: end;
}

+ 26
- 14
server/sonar-web/src/main/js/components/charts/ColorGradientLegend.tsx View File

@@ -17,16 +17,17 @@
* 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 { ScaleLinear, ScaleOrdinal } from 'd3-scale';
import { CSSColor, themeColor } from 'design-system';
import * as React from 'react';
import { colors } from '../../app/theme';
import './ColorGradientLegend.css';

interface Props {
className?: string;
colorScale:
| ScaleOrdinal<string, string> // used for LEVEL type
| ScaleLinear<string, string | number>; // used for RATING or PERCENT type
naColors?: [CSSColor, CSSColor];
height: number;
padding?: [number, number, number, number];
showColorNA?: boolean;
@@ -42,6 +43,7 @@ export default function ColorGradientLegend({
padding = [12, 24, 0, 0],
height,
showColorNA = false,
naColors = ['rgb(36,36,36)', 'rgb(120,120,120)'],
width,
}: Props) {
const colorRange: Array<string | number> = colorScale.range();
@@ -74,14 +76,14 @@ export default function ColorGradientLegend({
y1="0"
x2={i}
y2="30"
style={{ stroke: colors.gray71, strokeWidth: NA_SPACING }}
style={{ stroke: naColors[0], strokeWidth: NA_SPACING }}
/>
<line
x1={i + NA_SPACING}
y1="0"
x2={i + NA_SPACING}
y2="30"
style={{ stroke: colors.gray60, strokeWidth: NA_SPACING }}
style={{ stroke: naColors[1], strokeWidth: NA_SPACING }}
/>
</React.Fragment>
))}
@@ -90,8 +92,7 @@ export default function ColorGradientLegend({
<g transform={`translate(${padding[3]}, ${padding[0]})`}>
<rect fill="url(#gradient-legend)" height={rectHeight} width={widthNoPadding} x={0} y={0} />
{colorDomain.map((d, idx) => (
<text
className="gradient-legend-text"
<GradientLegendText
dy="-2px"
// eslint-disable-next-line react/no-array-index-key
key={idx}
@@ -99,7 +100,7 @@ export default function ColorGradientLegend({
y={0}
>
{d}
</text>
</GradientLegendText>
))}
</g>
{showColorNA && (
@@ -111,16 +112,27 @@ export default function ColorGradientLegend({
x={NA_SPACING}
y={0}
/>
<text
className="gradient-legend-na"
dy="-2px"
x={NA_SPACING + (padding[1] - NA_SPACING) / 2}
y={0}
>
<GradientLegendTextBase dy="-2px" x={NA_SPACING + (padding[1] - NA_SPACING) / 2} y={0}>
N/A
</text>
</GradientLegendTextBase>
</g>
)}
</svg>
);
}

const GradientLegendTextBase = styled.text`
text-anchor: middle;
fill: ${themeColor('pageContent')};
font-size: 10px;
`;

const GradientLegendText = styled(GradientLegendTextBase)`
&:first-of-type {
text-anchor: start;
}

&:last-of-type {
text-anchor: end;
}
`;

+ 18
- 12
server/sonar-web/src/main/js/components/measure/Measure.tsx View File

@@ -17,11 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { QualityGateIndicator } from 'design-system';
import { MetricsLabel, MetricsRatingBadge, QualityGateIndicator } from 'design-system';
import * as React from 'react';
import Tooltip from '../../components/controls/Tooltip';
import Rating from '../../components/ui/Rating';
import { translateWithParameters } from '../../helpers/l10n';
import { translate, translateWithParameters } from '../../helpers/l10n';
import { formatMeasure } from '../../helpers/measures';
import { MetricType } from '../../types/metrics';
import { Status } from '../../types/types';
@@ -76,14 +75,21 @@ export default function Measure({
}

const tooltip = <RatingTooltipContent metricKey={metricKey} value={value} />;
const rating = ratingComponent ?? <Rating value={value} />;
const rating = ratingComponent ?? (
<MetricsRatingBadge
size={small ? 'sm' : 'md'}
label={
value
? translateWithParameters('metric.has_rating_X', formatMeasure(value, MetricType.Rating))
: translate('metric.no_rating')
}
rating={formatMeasure(value, MetricType.Rating) as MetricsLabel}
/>
);

if (tooltip) {
return (
<Tooltip overlay={tooltip}>
<span className={className}>{rating}</span>
</Tooltip>
);
}
return rating;
return (
<Tooltip overlay={tooltip}>
<span className={className}>{rating}</span>
</Tooltip>
);
}

Loading…
Cancel
Save