From bc471deaba7bad449dcd10e9def1c1b0c808661c Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:14:50 +0530 Subject: [PATCH] [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. --- .../components/GraphWidgetGaugeChart.tsx | 216 ------------- .../graph/components/GraphWidgetRenderer.tsx | 12 +- .../GraphWidgetGaugeChart.stories.tsx | 2 +- .../GraphWidgetNumberChart.stories.tsx | 2 +- .../components/GaugeChartEndLine.tsx | 49 +++ .../components/GraphWidgetGaugeChart.tsx | 179 ++++++++++ .../hooks/__tests__/useGaugeChartData.test.ts | 305 ++++++++++++++++++ .../hooks/useGaugeChartData.ts | 68 ++++ .../hooks/useGaugeChartHandlers.ts | 26 ++ .../hooks/useGaugeChartTooltip.ts | 45 +++ .../types/GaugeChartData.ts | 10 + ...culateGaugeChartEndLineCoordinates.test.ts | 168 ++++++++++ .../calculateGaugeChartEndLineCoordinates.ts | 16 + .../components/GraphWidgetNumberChart.tsx | 12 +- .../utils/formatNumberChartTrend.ts | 3 + 15 files changed, 884 insertions(+), 229 deletions(-) delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetNumberChart}/components/GraphWidgetNumberChart.tsx (82%) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx deleted file mode 100644 index 1c65dabd163..00000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetGaugeChart.tsx +++ /dev/null @@ -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 ( - - ); - }; - - 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 ( - - - - ); - }; - - return ( - - - 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 && ( - - )} - - - - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx index fafc567dce2..ce3dbdd6b5d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx @@ -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 = { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx index 7234459b4e3..19c3329eff8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetGaugeChart.stories.tsx @@ -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 = { title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx index c18694dce96..451c5a11af6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetNumberChart.stories.tsx @@ -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 = { title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx new file mode 100644 index 00000000000..296f626614b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GaugeChartEndLine.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx new file mode 100644 index 00000000000..f997a9eae0c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart.tsx @@ -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) => ( + + ); + + const renderTooltip = () => { + const tooltipData = createTooltipData(); + return ( + + ); + }; + + return ( + + + 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 && ( + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts new file mode 100644 index 00000000000..33a384ece5e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/__tests__/useGaugeChartData.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts new file mode 100644 index 00000000000..3e9ad6e03de --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartData.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts new file mode 100644 index 00000000000..002b06ff5d5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartHandlers.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts new file mode 100644 index 00000000000..9b95ddb0499 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/hooks/useGaugeChartTooltip.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts new file mode 100644 index 00000000000..f6462f3c703 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/types/GaugeChartData.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts new file mode 100644 index 00000000000..b234bc21374 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/__tests__/calculateGaugeChartEndLineCoordinates.test.ts @@ -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); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts new file mode 100644 index 00000000000..1f7066d8880 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetGaugeChart/utils/calculateGaugeChartEndLineCoordinates.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx similarity index 82% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx index d977b5cd5b0..cf815ff0b16 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetNumberChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart.tsx @@ -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 ( @@ -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 - + )} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts new file mode 100644 index 00000000000..d7b432561fb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend.ts @@ -0,0 +1,3 @@ +export const formatNumberChartTrend = (trendPercentage: number): string => { + return trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`; +};