[Dashboards] Graph gauge chart component (#14035)

closes https://github.com/twentyhq/core-team-issues/issues/1374
figma -
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=72808-199932&t=C0BfdTmTl69RKZvU-0

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
nitin
2025-08-22 13:05:47 +05:30
committed by GitHub
parent 953f1e7207
commit 9e9fa6325f
9 changed files with 799 additions and 62 deletions
@@ -0,0 +1,238 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { ResponsiveRadialBar } from '@nivo/radial-bar';
import { useState } from 'react';
import {
AppTooltip,
H1Title,
H1TitleFontColor,
IconArrowUpRight,
TooltipDelay,
} from 'twenty-ui/display';
type GraphWidgetGaugeChartProps = {
value: number;
min: number;
max: number;
unit: string;
showValue?: boolean;
legendLabel: string;
tooltipHref: string;
id: string;
};
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledChartContainer = styled.div`
flex: 1;
position: relative;
width: 100%;
`;
const StyledH1Title = styled(H1Title)`
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -150%);
`;
const StyledLegendContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
`;
const StyledLegendItem = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledLegendLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledLegendValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledTooltipContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTooltipRow = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.extraLight};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDot = styled.div`
background: ${({ theme }) => theme.color.blue};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
const StyledTooltipValue = styled.span`
margin-left: auto;
white-space: nowrap;
`;
const StyledTooltipLink = styled.a`
display: flex;
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
text-decoration: none;
&:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const GraphWidgetGaugeChart = ({
value,
min,
max,
unit,
showValue = true,
legendLabel,
tooltipHref,
id,
}: GraphWidgetGaugeChartProps) => {
const theme = useTheme();
const [isHovered, setIsHovered] = useState(false);
const formatValue = (val: number): string => {
if (val % 1 !== 0) {
return val.toFixed(1);
}
return val.toString();
};
const displayValue = formatValue(value);
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
const data = [
{
id: 'gauge',
data: [
{ x: 'value', y: clampedNormalizedValue },
{ x: 'empty', y: 100 - clampedNormalizedValue },
],
},
];
const gradientColors = isHovered
? {
start: theme.adaptiveColors.blue4,
end: theme.adaptiveColors.blue3,
}
: {
start: theme.adaptiveColors.blue2,
end: theme.adaptiveColors.blue1,
};
const gradientId = `gaugeGradient-${id}`;
const defs = [
{
id: gradientId,
type: 'linearGradient',
colors: [
{ offset: 0, color: gradientColors.start },
{ offset: 100, color: gradientColors.end },
],
},
];
const tooltipContent = (
<StyledTooltipContent>
<StyledTooltipRow>
<StyledDot />
<span>{legendLabel}</span>
<StyledTooltipValue>{`${displayValue}${unit}`}</StyledTooltipValue>
</StyledTooltipRow>
<StyledTooltipLink href={tooltipHref}>
<span>{t`Click to see data`}</span>
<IconArrowUpRight size={theme.icon.size.sm} />
</StyledTooltipLink>
</StyledTooltipContent>
);
return (
<>
<StyledContainer
id={id}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<StyledChartContainer>
<ResponsiveRadialBar
data={data}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => d.x === 'value',
id: gradientId,
},
]}
enableTracks={false}
enableRadialGrid={false}
enableCircularGrid={false}
enableLabels={false}
isInteractive={false}
radialAxisStart={null}
radialAxisEnd={null}
circularAxisInner={null}
circularAxisOuter={null}
/>
{showValue && (
<StyledH1Title
title={`${displayValue}${unit}`}
fontColor={H1TitleFontColor.Primary}
/>
)}
</StyledChartContainer>
<StyledLegendContainer>
<StyledLegendItem>
<StyledDot />
<StyledLegendLabel>{legendLabel}</StyledLegendLabel>
<StyledLegendValue>{`${displayValue}${unit}`}</StyledLegendValue>
</StyledLegendItem>
</StyledLegendContainer>
</StyledContainer>
<AppTooltip
anchorSelect={`#${id}`}
place="top-start"
noArrow
delay={TooltipDelay.noDelay}
clickable
>
{tooltipContent}
</AppTooltip>
</>
);
};
@@ -0,0 +1,253 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
const meta: Meta<typeof GraphWidgetGaugeChart> = {
title: 'Modules/Dashboards/Graphs/GraphWidgetGaugeChart',
component: GraphWidgetGaugeChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
value: {
control: { type: 'number' },
},
min: {
control: { type: 'number' },
},
max: {
control: { type: 'number' },
},
unit: {
control: 'text',
},
showValue: {
control: 'boolean',
},
legendLabel: {
control: 'text',
},
tooltipHref: {
control: 'text',
},
id: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetGaugeChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '250px', height: '200px' }}>{children}</div>
);
export const Default: Story = {
args: {
value: 50,
min: 0,
max: 100,
unit: '%',
showValue: true,
legendLabel: 'Conversion rate',
tooltipHref: 'https://example.com',
id: 'gauge-chart-default',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'value',
values: [0, 25, 50, 75, 100],
props: (value: number) => ({
value,
min: 0,
max: 100,
unit: '%',
id: `gauge-chart-catalog-${value}`,
}),
labels: (value: number) => {
const labelMap: Record<number, string> = {
0: 'Empty',
25: 'Quarter',
50: 'Half',
75: 'Three Quarters',
100: 'Full',
};
return labelMap[value] ?? `${value}%`;
},
},
],
},
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={true}
legendLabel="Percentage"
tooltipHref="https://example.com"
id={args.id}
/>
</Container>
),
};
export const WithoutValue: Story = {
args: {
value: 65,
min: 0,
max: 100,
unit: '%',
showValue: false,
legendLabel: 'Conversion rate',
tooltipHref: 'https://example.com',
id: 'gauge-chart-without-value',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Revenue: Story = {
args: {
value: 750,
min: 0,
max: 1000,
unit: 'K',
showValue: true,
legendLabel: 'Revenue',
tooltipHref: 'https://example.com',
id: 'gauge-chart-revenue',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Temperature: Story = {
args: {
value: 22,
min: -10,
max: 40,
unit: '°C',
showValue: true,
legendLabel: 'Temperature',
tooltipHref: 'https://example.com',
id: 'gauge-chart-temperature',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Storage: Story = {
args: {
value: 384,
min: 0,
max: 512,
unit: 'GB',
showValue: true,
legendLabel: 'Storage Used',
tooltipHref: 'https://example.com',
id: 'gauge-chart-storage',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
export const Rating: Story = {
args: {
value: 4.2,
min: 0,
max: 5,
unit: ' ⭐',
showValue: true,
legendLabel: 'Average Rating',
tooltipHref: 'https://example.com',
id: 'gauge-chart-rating',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
value={args.value}
min={args.min}
max={args.max}
unit={args.unit}
showValue={args.showValue}
legendLabel={args.legendLabel}
tooltipHref={args.tooltipHref}
id={args.id}
/>
</Container>
),
};
@@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import type { Point } from '@nivo/line';
import type { Point, LineSeries } from '@nivo/line';
import { type ReactElement } from 'react';
const StyledTooltipContainer = styled.div`
@@ -45,7 +45,7 @@ const StyledTooltipValue = styled.span`
type WorkerMetricsTooltipProps = {
slice: {
points: readonly Point[];
points: readonly Point<LineSeries>[];
};
};
@@ -55,11 +55,11 @@ export const WorkerMetricsTooltip = ({
return (
<StyledTooltipContainer>
{slice.points.map((point) => (
<StyledTooltipItem key={point.id} color={point.serieColor}>
<StyledTooltipColorCircle color={point.serieColor} />
<StyledTooltipItem key={point.id} color={point.seriesColor}>
<StyledTooltipColorCircle color={point.seriesColor} />
<StyledTooltipDataRow>
<span>{point.serieId}</span>
<StyledTooltipValue>{String(point.data.y)}</StyledTooltipValue>
<span>{point.seriesId}</span>
<StyledTooltipValue>{point.data.yFormatted}</StyledTooltipValue>
</StyledTooltipDataRow>
</StyledTooltipItem>
))}