[Dashboards] - refactor - gauge and number chart (#14550)
This is the fourth and last PR from the split of https://github.com/twentyhq/twenty/pull/14458 - refactoring PieChart and NumberChart widgets.
This commit is contained in:
-216
@@ -1,216 +0,0 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
type RadialBarCustomLayerProps,
|
||||
ResponsiveRadialBar,
|
||||
} from '@nivo/radial-bar';
|
||||
import { useId, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
import { type GraphColor } from '../types/GraphColor';
|
||||
import { createGradientDef } from '../utils/createGradientDef';
|
||||
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
|
||||
import { getColorScheme } from '../utils/getColorScheme';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '../utils/graphFormatters';
|
||||
import { GraphWidgetChartContainer } from './GraphWidgetChartContainer';
|
||||
import { GraphWidgetLegend } from './GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
|
||||
|
||||
type GaugeChartData = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
color?: GraphColor;
|
||||
to?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type GraphWidgetGaugeChartProps = {
|
||||
data: GaugeChartData;
|
||||
showValue?: boolean;
|
||||
showLegend?: boolean;
|
||||
id: string;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledH1Title = styled(H1Title)`
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -150%);
|
||||
`;
|
||||
|
||||
export const GraphWidgetGaugeChart = ({
|
||||
data,
|
||||
showValue = true,
|
||||
showLegend = true,
|
||||
id,
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
}: GraphWidgetGaugeChartProps) => {
|
||||
const { value, min, max, color = 'blue', to, label = 'Value' } = data;
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
const colorScheme = getColorScheme(colorRegistry, color);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
};
|
||||
|
||||
const formattedValue = formatGraphValue(value, formatOptions);
|
||||
|
||||
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
|
||||
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
|
||||
|
||||
const chartData = [
|
||||
{
|
||||
id: 'gauge',
|
||||
data: [
|
||||
{ x: 'value', y: clampedNormalizedValue },
|
||||
{ x: 'empty', y: 100 - clampedNormalizedValue },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const gradientId = `gaugeGradient-${id}-${instanceId}`;
|
||||
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
|
||||
const gradientDef = createGradientDef(
|
||||
colorScheme,
|
||||
gradientId,
|
||||
isHovered,
|
||||
gaugeAngle,
|
||||
);
|
||||
const defs = [gradientDef];
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDefined(to)) {
|
||||
window.location.href = to;
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltip = () => {
|
||||
const formattedWithPercentage = `${formattedValue} (${normalizedValue.toFixed(1)}%)`;
|
||||
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[
|
||||
{
|
||||
label: label,
|
||||
formattedValue: formattedWithPercentage,
|
||||
dotColor: colorScheme.solid,
|
||||
},
|
||||
]}
|
||||
showClickHint={isDefined(to)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderValueEndLine = (props: RadialBarCustomLayerProps) => {
|
||||
if (clampedNormalizedValue === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { center, bars } = props;
|
||||
|
||||
const valueBar = bars?.find((bar) => bar.data.x === 'value');
|
||||
if (!valueBar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endAngle = valueBar.arc.endAngle - Math.PI / 2;
|
||||
const arcInnerRadius = valueBar.arc.innerRadius;
|
||||
const arcOuterRadius = valueBar.arc.outerRadius;
|
||||
|
||||
const [centerX, centerY] = center;
|
||||
const x1 = centerX + Math.cos(endAngle) * arcInnerRadius;
|
||||
const y1 = centerY + Math.sin(endAngle) * arcInnerRadius;
|
||||
const x2 = centerX + Math.cos(endAngle) * arcOuterRadius;
|
||||
const y2 = centerY + Math.sin(endAngle) * arcOuterRadius;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={colorScheme.solid}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<GraphWidgetChartContainer
|
||||
$isClickable={isDefined(to)}
|
||||
$cursorSelector='svg g path[fill^="url(#"]'
|
||||
>
|
||||
<ResponsiveRadialBar
|
||||
data={chartData}
|
||||
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={true}
|
||||
tooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
layers={['bars', renderValueEndLine]}
|
||||
/>
|
||||
{showValue && (
|
||||
<StyledH1Title
|
||||
title={formattedValue}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
)}
|
||||
</GraphWidgetChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={[
|
||||
{
|
||||
id: 'gauge',
|
||||
label: label,
|
||||
formattedValue: formattedValue,
|
||||
color: colorScheme.solid,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { GraphType } from '@/page-layout/mocks/mockWidgets';
|
||||
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
|
||||
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
|
||||
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/components/GraphWidgetNumberChart';
|
||||
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
|
||||
import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
@@ -30,11 +30,11 @@ const GraphWidgetPieChart = lazy(() =>
|
||||
);
|
||||
|
||||
const GraphWidgetGaugeChart = lazy(() =>
|
||||
import('@/page-layout/widgets/graph/components/GraphWidgetGaugeChart').then(
|
||||
(module) => ({
|
||||
default: module.GraphWidgetGaugeChart,
|
||||
}),
|
||||
),
|
||||
import(
|
||||
'@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart'
|
||||
).then((module) => ({
|
||||
default: module.GraphWidgetGaugeChart,
|
||||
})),
|
||||
);
|
||||
|
||||
type GraphWidgetRendererProps = {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
|
||||
import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetGaugeChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { GraphWidgetNumberChart } from '../GraphWidgetNumberChart';
|
||||
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetNumberChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart',
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
|
||||
import { calculateGaugeChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates';
|
||||
import { type RadialBarCustomLayerProps } from '@nivo/radial-bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type GaugeChartEndLineProps = {
|
||||
center: RadialBarCustomLayerProps['center'];
|
||||
bars: RadialBarCustomLayerProps['bars'];
|
||||
clampedNormalizedValue: number;
|
||||
colorScheme: GraphColorScheme;
|
||||
};
|
||||
|
||||
export const GaugeChartEndLine = ({
|
||||
center,
|
||||
bars,
|
||||
clampedNormalizedValue,
|
||||
colorScheme,
|
||||
}: GaugeChartEndLineProps) => {
|
||||
if (clampedNormalizedValue === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueBar = bars?.find((bar) => bar.data.x === 'value');
|
||||
if (!isDefined(valueBar)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [centerX, centerY] = center;
|
||||
const { x1, y1, x2, y2 } = calculateGaugeChartEndLineCoordinates(
|
||||
valueBar.arc.endAngle,
|
||||
centerX,
|
||||
centerY,
|
||||
valueBar.arc.innerRadius,
|
||||
valueBar.arc.outerRadius,
|
||||
);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={colorScheme.solid}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
import { GaugeChartEndLine } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine';
|
||||
import { useGaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData';
|
||||
import { useGaugeChartHandlers } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers';
|
||||
import { useGaugeChartTooltip } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip';
|
||||
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
type RadialBarCustomLayerProps,
|
||||
ResponsiveRadialBar,
|
||||
} from '@nivo/radial-bar';
|
||||
import { useId } from 'react';
|
||||
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
|
||||
|
||||
type GraphWidgetGaugeChartProps = {
|
||||
data: GaugeChartData;
|
||||
showValue?: boolean;
|
||||
showLegend?: boolean;
|
||||
id: string;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
${({ $isClickable }) =>
|
||||
$isClickable &&
|
||||
`
|
||||
svg g path[fill^="url(#"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledH1Title = styled(H1Title)`
|
||||
left: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -150%);
|
||||
`;
|
||||
|
||||
export const GraphWidgetGaugeChart = ({
|
||||
data,
|
||||
showValue = true,
|
||||
showLegend = true,
|
||||
id,
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
}: GraphWidgetGaugeChartProps) => {
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
};
|
||||
|
||||
const { isHovered, setIsHovered, handleClick, hasClickableItems } =
|
||||
useGaugeChartHandlers({ data });
|
||||
|
||||
const {
|
||||
colorScheme,
|
||||
normalizedValue,
|
||||
clampedNormalizedValue,
|
||||
chartData,
|
||||
gradientId,
|
||||
defs,
|
||||
} = useGaugeChartData({
|
||||
data,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
isHovered,
|
||||
});
|
||||
|
||||
const { createTooltipData } = useGaugeChartTooltip({
|
||||
value: data.value,
|
||||
normalizedValue,
|
||||
label: data.label || t`Value`,
|
||||
colorScheme,
|
||||
formatOptions,
|
||||
to: data.to,
|
||||
});
|
||||
|
||||
const formattedValue = formatGraphValue(data.value, formatOptions);
|
||||
|
||||
const renderValueEndLine = (props: RadialBarCustomLayerProps) => (
|
||||
<GaugeChartEndLine
|
||||
center={props.center}
|
||||
bars={props.bars}
|
||||
clampedNormalizedValue={clampedNormalizedValue}
|
||||
colorScheme={colorScheme}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderTooltip = () => {
|
||||
const tooltipData = createTooltipData();
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
showClickHint={tooltipData.showClickHint}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledChartContainer $isClickable={hasClickableItems}>
|
||||
<ResponsiveRadialBar
|
||||
data={chartData}
|
||||
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={true}
|
||||
tooltip={renderTooltip}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
layers={['bars', renderValueEndLine]}
|
||||
/>
|
||||
{showValue && (
|
||||
<StyledH1Title
|
||||
title={formattedValue}
|
||||
fontColor={H1TitleFontColor.Primary}
|
||||
/>
|
||||
)}
|
||||
</StyledChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={[
|
||||
{
|
||||
id: 'gauge',
|
||||
label: data.label || t`Value`,
|
||||
formattedValue: formattedValue,
|
||||
color: colorScheme.solid,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useGaugeChartData } from '../useGaugeChartData';
|
||||
|
||||
describe('useGaugeChartData', () => {
|
||||
const mockColorRegistry: GraphColorRegistry = {
|
||||
blue: {
|
||||
name: 'blue',
|
||||
gradient: {
|
||||
normal: ['blue1', 'blue2'],
|
||||
hover: ['blue3', 'blue4'],
|
||||
},
|
||||
solid: 'blueSolid',
|
||||
},
|
||||
green: {
|
||||
name: 'green',
|
||||
gradient: {
|
||||
normal: ['green1', 'green2'],
|
||||
hover: ['green3', 'green4'],
|
||||
},
|
||||
solid: 'greenSolid',
|
||||
},
|
||||
};
|
||||
|
||||
it('should calculate normalized value correctly', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 75,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.normalizedValue).toBe(75);
|
||||
expect(result.current.clampedNormalizedValue).toBe(75);
|
||||
});
|
||||
|
||||
it('should handle min and max with non-zero min', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 150,
|
||||
min: 100,
|
||||
max: 200,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.normalizedValue).toBe(50);
|
||||
expect(result.current.clampedNormalizedValue).toBe(50);
|
||||
});
|
||||
|
||||
it('should clamp values above 100', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 150,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.normalizedValue).toBe(150);
|
||||
expect(result.current.clampedNormalizedValue).toBe(100);
|
||||
});
|
||||
|
||||
it('should clamp values below 0', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: -25,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.normalizedValue).toBe(-25);
|
||||
expect(result.current.clampedNormalizedValue).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle equal min and max', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 100,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.normalizedValue).toBe(0);
|
||||
expect(result.current.clampedNormalizedValue).toBe(0);
|
||||
});
|
||||
|
||||
it('should use custom color when provided', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
color: 'green',
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.colorScheme).toBe(mockColorRegistry.green);
|
||||
});
|
||||
|
||||
it('should default to blue color when not provided', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.colorScheme).toBe(mockColorRegistry.blue);
|
||||
});
|
||||
|
||||
it('should generate correct chart data structure', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 30,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.chartData).toEqual([
|
||||
{
|
||||
id: 'gauge',
|
||||
data: [
|
||||
{ x: 'value', y: 30 },
|
||||
{ x: 'empty', y: 70 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate gradient with correct angle', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defs[0].id).toBe(
|
||||
'gaugeGradient-test-gauge-instance-1',
|
||||
);
|
||||
const expectedAngle = -45;
|
||||
const expectedRadians = (expectedAngle * Math.PI) / 180 + Math.PI / 2;
|
||||
const expectedSin = Math.sin(expectedRadians);
|
||||
const expectedCos = -Math.cos(expectedRadians);
|
||||
const expectedX1 = 50 - expectedSin * 50;
|
||||
const expectedY1 = 50 - expectedCos * 50;
|
||||
|
||||
const actualX1 = parseFloat(result.current.defs[0].x1);
|
||||
const actualY1 = parseFloat(result.current.defs[0].y1);
|
||||
expect(actualX1).toBeCloseTo(expectedX1, 5);
|
||||
expect(actualY1).toBeCloseTo(expectedY1, 5);
|
||||
});
|
||||
|
||||
it('should handle hover state', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.defs[0].colors).toEqual([
|
||||
{ offset: 0, color: 'blue3' },
|
||||
{ offset: 100, color: 'blue4' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate unique gradient id', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'unique-id',
|
||||
instanceId: 'unique-instance',
|
||||
isHovered: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.gradientId).toBe(
|
||||
'gaugeGradient-unique-id-unique-instance',
|
||||
);
|
||||
});
|
||||
|
||||
it('should memoize calculations', () => {
|
||||
const data: GaugeChartData = {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ isHovered }) =>
|
||||
useGaugeChartData({
|
||||
data,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-gauge',
|
||||
instanceId: 'instance-1',
|
||||
isHovered,
|
||||
}),
|
||||
{ initialProps: { isHovered: false } },
|
||||
);
|
||||
|
||||
const firstColorScheme = result.current.colorScheme;
|
||||
const firstChartData = result.current.chartData;
|
||||
|
||||
rerender({ isHovered: false });
|
||||
|
||||
expect(result.current.colorScheme).toBe(firstColorScheme);
|
||||
expect(result.current.chartData).toBe(firstChartData);
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
|
||||
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type UseGaugeChartDataProps = {
|
||||
data: GaugeChartData;
|
||||
colorRegistry: GraphColorRegistry;
|
||||
id: string;
|
||||
instanceId: string;
|
||||
isHovered: boolean;
|
||||
};
|
||||
|
||||
export const useGaugeChartData = ({
|
||||
data,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
isHovered,
|
||||
}: UseGaugeChartDataProps) => {
|
||||
const { value, min, max, color = 'blue' } = data;
|
||||
|
||||
const colorScheme = useMemo(
|
||||
() => getColorScheme(colorRegistry, color),
|
||||
[colorRegistry, color],
|
||||
);
|
||||
|
||||
const normalizedValue = useMemo(
|
||||
() => (max === min ? 0 : ((value - min) / (max - min)) * 100),
|
||||
[value, min, max],
|
||||
);
|
||||
|
||||
const clampedNormalizedValue = useMemo(
|
||||
() => Math.max(0, Math.min(100, normalizedValue)),
|
||||
[normalizedValue],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'gauge',
|
||||
data: [
|
||||
{ x: 'value', y: clampedNormalizedValue },
|
||||
{ x: 'empty', y: 100 - clampedNormalizedValue },
|
||||
],
|
||||
},
|
||||
],
|
||||
[clampedNormalizedValue],
|
||||
);
|
||||
|
||||
const gradientId = `gaugeGradient-${id}-${instanceId}`;
|
||||
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
|
||||
|
||||
const defs = useMemo(
|
||||
() => [createGradientDef(colorScheme, gradientId, isHovered, gaugeAngle)],
|
||||
[colorScheme, gradientId, isHovered, gaugeAngle],
|
||||
);
|
||||
|
||||
return {
|
||||
colorScheme,
|
||||
normalizedValue,
|
||||
clampedNormalizedValue,
|
||||
chartData,
|
||||
gradientId,
|
||||
defs,
|
||||
};
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { type GaugeChartData } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseGaugeChartHandlersProps = {
|
||||
data: GaugeChartData;
|
||||
};
|
||||
|
||||
export const useGaugeChartHandlers = ({ data }: UseGaugeChartHandlersProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (isDefined(data.to)) {
|
||||
window.location.href = data.to;
|
||||
}
|
||||
};
|
||||
|
||||
const hasClickableItems = isDefined(data.to);
|
||||
|
||||
return {
|
||||
isHovered,
|
||||
setIsHovered,
|
||||
handleClick,
|
||||
hasClickableItems,
|
||||
};
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseGaugeChartTooltipProps = {
|
||||
value: number;
|
||||
normalizedValue: number;
|
||||
label: string;
|
||||
colorScheme: GraphColorScheme;
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export const useGaugeChartTooltip = ({
|
||||
value,
|
||||
normalizedValue,
|
||||
label,
|
||||
colorScheme,
|
||||
formatOptions,
|
||||
to,
|
||||
}: UseGaugeChartTooltipProps) => {
|
||||
const createTooltipData = () => {
|
||||
// Format value based on display type to avoid redundant percentage display
|
||||
const formattedValue =
|
||||
formatOptions?.displayType === 'percentage'
|
||||
? formatGraphValue(normalizedValue / 100, formatOptions)
|
||||
: `${formatGraphValue(value, formatOptions)} (${normalizedValue.toFixed(1)}%)`;
|
||||
|
||||
return {
|
||||
tooltipItem: {
|
||||
label: label,
|
||||
formattedValue,
|
||||
dotColor: colorScheme.solid,
|
||||
},
|
||||
showClickHint: isDefined(to),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createTooltipData,
|
||||
};
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
|
||||
|
||||
export type GaugeChartData = {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
color?: GraphColor;
|
||||
to?: string;
|
||||
label?: string;
|
||||
};
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { calculateGaugeChartEndLineCoordinates } from '../calculateGaugeChartEndLineCoordinates';
|
||||
|
||||
describe('calculateGaugeChartEndLineCoordinates', () => {
|
||||
it('should calculate coordinates for angle π/2 (after adjustment points right)', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
Math.PI / 2,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: 150,
|
||||
y1: 100,
|
||||
x2: 180,
|
||||
y2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate coordinates for angle π (after adjustment points down)', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
Math.PI,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: 100,
|
||||
y1: 150,
|
||||
x2: 100,
|
||||
y2: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate coordinates for angle 3π/2 (after adjustment points left)', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
(3 * Math.PI) / 2,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
expect(result.x1).toBeCloseTo(50, 5);
|
||||
expect(result.y1).toBeCloseTo(100, 5);
|
||||
expect(result.x2).toBeCloseTo(20, 5);
|
||||
expect(result.y2).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('should calculate coordinates for angle 0 (after adjustment points up)', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(0, 100, 100, 50, 80);
|
||||
expect(result.x1).toBeCloseTo(100, 5);
|
||||
expect(result.y1).toBeCloseTo(50, 5);
|
||||
expect(result.x2).toBeCloseTo(100, 5);
|
||||
expect(result.y2).toBeCloseTo(20, 5);
|
||||
});
|
||||
|
||||
it('should calculate coordinates for 45-degree angle', () => {
|
||||
const angle = Math.PI / 4;
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
angle,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
const adjustedAngle = angle - Math.PI / 2;
|
||||
const expectedCos = Math.cos(adjustedAngle);
|
||||
const expectedSin = Math.sin(adjustedAngle);
|
||||
expect(result.x1).toBeCloseTo(100 + expectedCos * 50, 5);
|
||||
expect(result.y1).toBeCloseTo(100 + expectedSin * 50, 5);
|
||||
expect(result.x2).toBeCloseTo(100 + expectedCos * 80, 5);
|
||||
expect(result.y2).toBeCloseTo(100 + expectedSin * 80, 5);
|
||||
});
|
||||
|
||||
it('should handle different center positions', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
Math.PI,
|
||||
200,
|
||||
150,
|
||||
30,
|
||||
60,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: 200,
|
||||
y1: 180,
|
||||
x2: 200,
|
||||
y2: 210,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero radius', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
Math.PI / 4,
|
||||
100,
|
||||
100,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: 100,
|
||||
y1: 100,
|
||||
x2: 100,
|
||||
y2: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle negative angles', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
-Math.PI / 2,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
expect(result.x1).toBeCloseTo(50, 5);
|
||||
expect(result.y1).toBeCloseTo(100, 5);
|
||||
expect(result.x2).toBeCloseTo(20, 5);
|
||||
expect(result.y2).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('should create a line from inner to outer radius', () => {
|
||||
const angle = Math.PI / 6;
|
||||
const centerX = 100;
|
||||
const centerY = 100;
|
||||
const innerRadius = 40;
|
||||
const outerRadius = 70;
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
angle,
|
||||
centerX,
|
||||
centerY,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
);
|
||||
const dist1 = Math.sqrt(
|
||||
(result.x1 - centerX) ** 2 + (result.y1 - centerY) ** 2,
|
||||
);
|
||||
const dist2 = Math.sqrt(
|
||||
(result.x2 - centerX) ** 2 + (result.y2 - centerY) ** 2,
|
||||
);
|
||||
expect(dist1).toBeCloseTo(innerRadius, 5);
|
||||
expect(dist2).toBeCloseTo(outerRadius, 5);
|
||||
const angle1 = Math.atan2(result.y1 - centerY, result.x1 - centerX);
|
||||
const angle2 = Math.atan2(result.y2 - centerY, result.x2 - centerX);
|
||||
expect(angle1).toBeCloseTo(angle2, 5);
|
||||
});
|
||||
|
||||
it('should handle angles greater than 2π', () => {
|
||||
const result = calculateGaugeChartEndLineCoordinates(
|
||||
2.5 * Math.PI,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
const equivalentResult = calculateGaugeChartEndLineCoordinates(
|
||||
0.5 * Math.PI,
|
||||
100,
|
||||
100,
|
||||
50,
|
||||
80,
|
||||
);
|
||||
expect(result.x1).toBeCloseTo(equivalentResult.x1, 10);
|
||||
expect(result.y1).toBeCloseTo(equivalentResult.y1, 10);
|
||||
expect(result.x2).toBeCloseTo(equivalentResult.x2, 10);
|
||||
expect(result.y2).toBeCloseTo(equivalentResult.y2, 10);
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
export const calculateGaugeChartEndLineCoordinates = (
|
||||
endAngle: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
innerRadius: number,
|
||||
outerRadius: number,
|
||||
) => {
|
||||
const adjustedAngle = endAngle - Math.PI / 2;
|
||||
|
||||
const x1 = centerX + Math.cos(adjustedAngle) * innerRadius;
|
||||
const y1 = centerY + Math.sin(adjustedAngle) * innerRadius;
|
||||
const x2 = centerX + Math.cos(adjustedAngle) * outerRadius;
|
||||
const y2 = centerY + Math.sin(adjustedAngle) * outerRadius;
|
||||
|
||||
return { x1, y1, x2, y2 };
|
||||
};
|
||||
+7
-5
@@ -1,3 +1,4 @@
|
||||
import { formatNumberChartTrend } from '@/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
IconTrendingUp,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
// props are subjected to change
|
||||
type GraphWidgetNumberChartProps = {
|
||||
value: string;
|
||||
trendPercentage: number;
|
||||
@@ -43,8 +43,8 @@ export const GraphWidgetNumberChart = ({
|
||||
trendPercentage,
|
||||
}: GraphWidgetNumberChartProps) => {
|
||||
const theme = useTheme();
|
||||
const formattedPercentage =
|
||||
trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
|
||||
const formattedPercentage = formatNumberChartTrend(trendPercentage);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledH1Title title={value} fontColor={H1TitleFontColor.Primary} />
|
||||
@@ -58,8 +58,10 @@ export const GraphWidgetNumberChart = ({
|
||||
size={theme.icon.size.md}
|
||||
/>
|
||||
) : (
|
||||
// question for product - whats the exact red here? cant see it on figma
|
||||
<IconTrendingDown color={theme.color.red} size={theme.icon.size.md} />
|
||||
<IconTrendingDown
|
||||
color={theme.adaptiveColors.red4}
|
||||
size={theme.icon.size.md}
|
||||
/>
|
||||
)}
|
||||
</StyledTrendIconContainer>
|
||||
</StyledContainer>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const formatNumberChartTrend = (trendPercentage: number): string => {
|
||||
return trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
|
||||
};
|
||||
Reference in New Issue
Block a user