From 9c3cd959ba446af41913bfad74d907678d892475 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:58:51 +0530 Subject: [PATCH] [Dashboards] add legend hover highlight for graph widgets (#16551) closes https://github.com/twentyhq/core-team-issues/issues/1959 line - https://github.com/user-attachments/assets/3fcefd47-c065-488c-a9e1-e820e8a03349 bar - https://github.com/user-attachments/assets/a138e376-9304-46b9-b6ba-c642f7b85ae2 pie - https://github.com/user-attachments/assets/cc008d0e-ec7f-47a3-ac14-5fef56426f6a onClick - https://github.com/user-attachments/assets/94c1791d-b820-449c-81f9-c3d10361b694 --- .../__tests__/GraphWidgetTestWrapper.tsx | 18 ++- .../graph/components/GraphWidgetLegend.tsx | 107 ++++++++++++++-- .../graph/components/GraphWidgetLegendDot.tsx | 8 +- .../GraphWidgetGaugeChart.stories.tsx | 12 +- .../__stories__/GraphWidgetLegend.stories.tsx | 10 +- .../GraphWidgetPieChart.stories.tsx | 34 ++---- .../LegendHighlightDimmedOpacity.constant.ts | 1 + .../components/CustomBarItem.tsx | 23 +++- .../components/GraphWidgetBarChart.tsx | 47 +++---- .../hooks/__tests__/useBarChartData.test.ts | 115 ++++++++++++++++++ .../hooks/useBarChartData.ts | 66 ++++++---- .../components/CustomLinesLayer.tsx | 80 ++++++++++++ .../components/CustomStackedAreasLayer.tsx | 2 +- .../components/GraphWidgetLineChart.tsx | 44 +++++-- .../components/LineAnimatedAreaPath.tsx | 31 +++-- .../hooks/__tests__/useLineChartData.test.ts | 90 ++++++++++++++ .../hooks/useLineChartData.ts | 55 ++++++--- .../components/CustomArcsLayer.tsx | 71 +++++++++++ .../components/GraphWidgetPieChart.tsx | 106 +++++++++------- .../hooks/__tests__/usePieChartData.test.ts | 103 ++++++++++++++-- .../hooks/usePieChartData.ts | 20 ++- .../graph/hooks/useLegendItemToggle.ts | 47 +++++++ ...raphWidgetHiddenLegendIdsComponentState.ts | 10 ++ ...WidgetHighlightedLegendIdComponentState.ts | 9 ++ 24 files changed, 923 insertions(+), 186 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomLinesLayer.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useLegendItemToggle.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper.tsx index 18d71520f8..1d3be4508f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper.tsx @@ -1,3 +1,4 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; import { type ReactNode } from 'react'; import { RecoilRoot, type MutableSnapshot } from 'recoil'; @@ -5,22 +6,33 @@ import { RecoilRoot, type MutableSnapshot } from 'recoil'; export const GRAPH_WIDGET_TEST_INSTANCE_ID = '30303030-f244-4ae0-906b-78958aa07642'; +export const PAGE_LAYOUT_TEST_INSTANCE_ID = + '20202020-f244-4ae0-906b-78958aa07642'; + export const GraphWidgetTestWrapper = ({ children, initializeState, instanceId: instanceIdFromProps, + pageLayoutInstanceId: pageLayoutInstanceIdFromProps, }: { children: ReactNode; initializeState?: (snapshot: MutableSnapshot) => void; instanceId?: string; + pageLayoutInstanceId?: string; }) => { const instanceId = instanceIdFromProps ?? GRAPH_WIDGET_TEST_INSTANCE_ID; + const pageLayoutInstanceId = + pageLayoutInstanceIdFromProps ?? PAGE_LAYOUT_TEST_INSTANCE_ID; return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegend.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegend.tsx index a22a28c391..651a2cc595 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegend.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegend.tsx @@ -1,8 +1,16 @@ +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot'; +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; import { LEGEND_ITEM_ESTIMATED_WIDTH } from '@/page-layout/widgets/graph/constants/LegendItemEstimatedWidth.constant'; import { LEGEND_LABEL_MAX_WIDTH } from '@/page-layout/widgets/graph/constants/LegendLabelMaxWidth.constant'; import { LEGEND_PAGINATION_CONTROLS_WIDTH } from '@/page-layout/widgets/graph/constants/LegendPaginationControlsWidth.constant'; +import { useLegendItemToggle } from '@/page-layout/widgets/graph/hooks/useLegendItemToggle'; +import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { AnimatePresence, motion } from 'framer-motion'; @@ -59,8 +67,13 @@ const StyledLegendContainer = styled.div<{ needsPagination: boolean }>` align-items: center; `; -const StyledLegendItem = styled.div<{ canShrink?: boolean }>` +const StyledLegendItem = styled.div<{ + canShrink?: boolean; + isHidden?: boolean; + isInteractive?: boolean; +}>` align-items: center; + cursor: ${({ isInteractive }) => (isInteractive ? 'pointer' : 'default')}; display: flex; gap: ${({ theme }) => theme.spacing(1)}; font-size: ${({ theme }) => theme.font.size.xs}; @@ -69,10 +82,25 @@ const StyledLegendItem = styled.div<{ canShrink?: boolean }>` min-width: 0; `; -const StyledLegendLabel = styled.div<{ fixedWidth?: boolean }>` +const StyledLegendLabel = styled.div<{ + fixedWidth?: boolean; + isHidden?: boolean; +}>` color: ${({ theme }) => theme.font.color.secondary}; ${({ fixedWidth }) => fixedWidth && `width: ${LEGEND_LABEL_MAX_WIDTH}px;`} overflow: hidden; + text-decoration: ${({ isHidden }) => (isHidden ? 'line-through' : 'none')}; + opacity: ${({ isHidden }) => + isHidden ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}; + + :hover { + opacity: 1; + } +`; + +const StyledLegendDot = styled(GraphWidgetLegendDot)<{ isHidden?: boolean }>` + opacity: ${({ isHidden }) => + isHidden ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}; `; const StyledPaginationContainer = styled.div` @@ -118,6 +146,45 @@ export const GraphWidgetLegend = ({ const theme = useTheme(); + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + + const isInteractive = !isPageLayoutInEditMode; + + const setHighlightedLegendId = useSetRecoilComponentState( + graphWidgetHighlightedLegendIdComponentState, + ); + + const [hiddenLegendIds, setHiddenLegendIds] = useRecoilComponentState( + graphWidgetHiddenLegendIdsComponentState, + ); + + const itemIds = items.map((item) => item.id); + + const { toggleLegendItem } = useLegendItemToggle({ + itemIds, + isInteractive, + }); + + if (isPageLayoutInEditMode && hiddenLegendIds.length > 0) { + setHiddenLegendIds([]); + } + + const handleLegendItemMouseEnter = (itemId: string) => { + if (!isInteractive) { + return; + } + setHighlightedLegendId(itemId); + }; + + const handleLegendItemMouseLeave = () => { + if (!isInteractive) { + return; + } + setHighlightedLegendId(null); + }; + const shouldShowLegend = show && items.length > 1; const availableWidth = containerWidth - LEGEND_PAGINATION_CONTROLS_WIDTH; @@ -234,17 +301,31 @@ export const GraphWidgetLegend = ({ }} centered={!needsPagination} > - {visibleItems.map((item) => ( - - - - - - - ))} + {visibleItems.map((item) => { + const isHidden = hiddenLegendIds.includes(item.id); + return ( + toggleLegendItem(item.id)} + onMouseEnter={() => handleLegendItemMouseEnter(item.id)} + onMouseLeave={handleLegendItemMouseLeave} + > + + + + + + ); + })} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegendDot.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegendDot.tsx index 591a5d394f..905afb73f1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegendDot.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetLegendDot.tsx @@ -2,6 +2,7 @@ import styled from '@emotion/styled'; type GraphWidgetLegendDotProps = { color: string; + className?: string; }; const StyledDot = styled.div<{ color: string }>` @@ -12,6 +13,9 @@ const StyledDot = styled.div<{ color: string }>` flex-shrink: 0; `; -export const GraphWidgetLegendDot = ({ color }: GraphWidgetLegendDotProps) => { - return ; +export const GraphWidgetLegendDot = ({ + color, + className, +}: GraphWidgetLegendDotProps) => { + return ; }; 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 cca3c5acc6..65ca104b99 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,12 +1,20 @@ import { type Meta, type StoryObj } from '@storybook/react'; -import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; +import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/graphWidgetGaugeChart/components/GraphWidgetGaugeChart'; +import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart', component: GraphWidgetGaugeChart, - decorators: [ComponentDecorator], + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], parameters: { layout: 'centered', }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLegend.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLegend.stories.tsx index 673886a022..3849f80df4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLegend.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLegend.stories.tsx @@ -1,12 +1,20 @@ import { type Meta, type StoryObj } from '@storybook/react'; +import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; import { ComponentDecorator } from 'twenty-ui/testing'; import { GraphWidgetLegend } from '../GraphWidgetLegend'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetLegend', component: GraphWidgetLegend, - decorators: [ComponentDecorator], + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], parameters: { layout: 'centered', }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx index 7a821b56e9..0c8f215a1d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx @@ -1,9 +1,8 @@ import { type Meta, type StoryObj } from '@storybook/react'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { GRAPH_WIDGET_TEST_INSTANCE_ID } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; +import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart'; -import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; import { AggregateOperations, @@ -12,7 +11,6 @@ import { } from '~/generated/graphql'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; -import { RootDecorator } from '~/testing/decorators/RootDecorator'; import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; @@ -36,17 +34,14 @@ const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart', component: GraphWidgetPieChart, decorators: [ - (Story) => ( - - - - ), - ComponentDecorator, I18nFrontDecorator, ObjectMetadataItemsDecorator, - RootDecorator, + (Story) => ( + + + + ), + ComponentDecorator, ], parameters: { layout: 'centered', @@ -369,17 +364,14 @@ export const Storage: Story = { export const Catalog: Story = { decorators: [ - (Story) => ( - - - - ), - CatalogDecorator, I18nFrontDecorator, ObjectMetadataItemsDecorator, - RootDecorator, + (Story) => ( + + + + ), + CatalogDecorator, ], parameters: { catalog: { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant.ts new file mode 100644 index 0000000000..a3e79192ed --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant.ts @@ -0,0 +1 @@ +export const LEGEND_HIGHLIGHT_DIMMED_OPACITY = 0.2; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx index 2f4df8ac3b..a4f1bfb03d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem.tsx @@ -1,6 +1,9 @@ +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; import { BAR_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartHoverBrightness'; import { BAR_CHART_MAXIMUM_WIDTH } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/MaximumBarWidth'; import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { type BarDatum, type BarItemProps } from '@nivo/bar'; import { animated, to } from '@react-spring/web'; import { isNumber } from '@sniptt/guards'; @@ -17,9 +20,16 @@ type CustomBarItemProps = BarItemProps & { chartId?: string; }; -const StyledBarRect = styled(animated.rect)<{ $isInteractive?: boolean }>` +const StyledBarRect = styled(animated.rect)<{ + $isInteractive?: boolean; + $isDimmed?: boolean; +}>` cursor: ${({ $isInteractive }) => ($isInteractive ? 'pointer' : 'default')}; - transition: filter 0.15s ease-in-out; + transition: + filter 0.15s ease-in-out, + opacity 0.15s ease-in-out; + opacity: ${({ $isDimmed }) => + $isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}; &:hover { filter: ${({ $isInteractive }) => @@ -50,6 +60,14 @@ export const CustomBarItem = ({ layout = BarChartLayout.VERTICAL, chartId, }: CustomBarItemProps) => { + const highlightedLegendId = useRecoilComponentValue( + graphWidgetHighlightedLegendIdComponentState, + ); + + const isDimmed = + isDefined(highlightedLegendId) && + String(highlightedLegendId) !== String(barData.id); + const handleClick = useCallback( (event: MouseEvent) => { onClick?.({ color: bar.color, ...barData }, event); @@ -197,6 +215,7 @@ export const CustomBarItem = ({ Math.max(value, 0))} height={to(finalBarHeightDimension, (value) => Math.max(value, 0))} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 9172241f5e..052ef6ab14 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -7,6 +7,7 @@ import { GraphBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBar import { BAR_CHART_OUTER_PADDING_RATIO } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartOuterPaddingRatio'; import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData'; import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme'; +import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; @@ -23,8 +24,8 @@ import { formatGraphValue, type GraphValueFormatOptions, } from '@/page-layout/widgets/graph/utils/graphFormatters'; - import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -39,9 +40,6 @@ import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useDebouncedCallback } from 'use-debounce'; -import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; -import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; - type NoDataLayerWrapperProps = BarCustomLayerProps; type GraphWidgetBarChartProps = { @@ -118,21 +116,22 @@ export const GraphWidgetBarChart = ({ const chartTheme = useBarChartTheme(); - const { barConfigs, enrichedKeys } = useBarChartData({ - data, - indexBy, - keys, - series, - colorRegistry, - seriesLabels, - }); + const { barConfigs, enrichedKeys, legendItems, visibleKeys } = + useBarChartData({ + data, + indexBy, + keys, + series, + colorRegistry, + seriesLabels, + }); const calculatedValueRange = groupMode === 'stacked' - ? calculateStackedBarChartValueRange(data, keys) - : calculateValueRangeFromBarChartKeys(data, keys); + ? calculateStackedBarChartValueRange(data, visibleKeys) + : calculateValueRangeFromBarChartKeys(data, visibleKeys); - const hasNoData = data.length === 0; + const hasNoData = data.length === 0 || visibleKeys.length === 0; const { effectiveMinimumValue, effectiveMaximumValue } = computeEffectiveValueRange({ @@ -206,7 +205,7 @@ export const GraphWidgetBarChart = ({ ), - [keys, groupMode, data, indexBy, layout, id], + [visibleKeys, groupMode, data, indexBy, layout, id], ); const TotalsLayer = ({ @@ -280,7 +279,7 @@ export const GraphWidgetBarChart = ({ { - return { - id: item.key, - label: item.label, - color: item.colorScheme.solid, - }; - })} + show={showLegend && data.length > 0 && keys.length > 0} + items={legendItems} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts index da786d3f34..4f321c8840 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts @@ -4,9 +4,18 @@ import { type BarDatum } from '@nivo/bar'; import { renderHook } from '@testing-library/react'; import { useBarChartData } from '../useBarChartData'; +const mockUseRecoilComponentValue = jest.fn(); +jest.mock( + '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue', + () => ({ + useRecoilComponentValue: () => mockUseRecoilComponentValue(), + }), +); + describe('useBarChartData', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseRecoilComponentValue.mockReturnValue([]); }); const mockColorRegistry: GraphColorRegistry = { @@ -192,4 +201,110 @@ describe('useBarChartData', () => { expect(result.current.enrichedKeys[0].label).toBe('sales'); expect(result.current.enrichedKeys[1].label).toBe('costs'); }); + + it('should return legend items from all keys', () => { + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.legendItems).toHaveLength(2); + expect(result.current.legendItems[0]).toMatchObject({ + id: 'sales', + label: 'Sales', + color: 'green5', + }); + }); + + it('should filter visible keys based on hidden legend ids', () => { + mockUseRecoilComponentValue.mockReturnValue(['costs']); + + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.visibleKeys).toEqual(['sales']); + expect(result.current.enrichedKeys).toHaveLength(1); + expect(result.current.enrichedKeys[0].key).toBe('sales'); + }); + + it('should maintain colors after filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['sales']); + + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedKeys[0].colorScheme.name).toBe('purple'); + }); + + it('should keep all items in legend even when filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['sales']); + + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.visibleKeys).toHaveLength(1); + expect(result.current.legendItems).toHaveLength(2); + }); + + it('should filter barConfigs to only include visible keys', () => { + mockUseRecoilComponentValue.mockReturnValue(['costs']); + + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.barConfigs).toHaveLength(3); + result.current.barConfigs.forEach((config) => { + expect(config.key).toBe('sales'); + }); + }); + + it('should handle hidden ids that do not exist in keys', () => { + mockUseRecoilComponentValue.mockReturnValue(['nonexistent', 'alsoNotReal']); + + const { result } = renderHook(() => + useBarChartData({ + data: mockData, + indexBy: 'month', + keys: ['sales', 'costs'], + series: mockSeries, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.visibleKeys).toEqual(['sales', 'costs']); + expect(result.current.enrichedKeys).toHaveLength(2); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts index 806a3a348e..6924a48430 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts @@ -1,10 +1,14 @@ +import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig'; import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { type BarDatum } from '@nivo/bar'; import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; type UseBarChartDataProps = { data: BarDatum[]; @@ -24,33 +28,16 @@ export const useBarChartData = ({ colorRegistry, seriesLabels, }: UseBarChartDataProps) => { + const hiddenLegendIds = useRecoilComponentValue( + graphWidgetHiddenLegendIdsComponentState, + ); + const seriesConfigMap = useMemo( () => new Map(series?.map((s) => [s.key, s]) || []), [series], ); - const barConfigs = useMemo((): BarChartConfig[] => { - return data.flatMap((dataPoint) => { - const indexValue = dataPoint[indexBy]; - return keys.map((key, keyIndex): BarChartConfig => { - const seriesConfig = seriesConfigMap.get(key); - const colorScheme = getColorScheme({ - registry: colorRegistry, - colorName: seriesConfig?.color, - fallbackIndex: keyIndex, - totalGroups: keys.length, - }); - - return { - key, - indexValue, - colorScheme, - }; - }); - }); - }, [data, indexBy, keys, colorRegistry, seriesConfigMap]); - - const enrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => { + const allEnrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => { const seriesConfig = seriesConfigMap.get(key); const colorScheme = getColorScheme({ registry: colorRegistry, @@ -62,13 +49,46 @@ export const useBarChartData = ({ return { key, colorScheme, - label: seriesConfig?.label || seriesLabels?.[key] || key, + label: seriesConfig?.label ?? seriesLabels?.[key] ?? key, }; }); + const legendItems: GraphWidgetLegendItem[] = allEnrichedKeys.map((item) => ({ + id: item.key, + label: item.label, + color: item.colorScheme.solid, + })); + + const visibleKeys = keys.filter((key) => !hiddenLegendIds.includes(key)); + + const enrichedKeys = allEnrichedKeys.filter( + (item) => !hiddenLegendIds.includes(item.key), + ); + + const barConfigs = useMemo((): BarChartConfig[] => { + return data.flatMap((dataPoint) => { + const indexValue = dataPoint[indexBy]; + return visibleKeys.flatMap((key): BarChartConfig[] => { + const enrichedKey = allEnrichedKeys.find((ek) => ek.key === key); + if (!isDefined(enrichedKey)) { + return []; + } + return [ + { + key, + indexValue, + colorScheme: enrichedKey.colorScheme, + }, + ]; + }); + }); + }, [data, indexBy, visibleKeys, allEnrichedKeys]); + return { seriesConfigMap, barConfigs, enrichedKeys, + legendItems, + visibleKeys, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomLinesLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomLinesLayer.tsx new file mode 100644 index 0000000000..b84d4bf5b3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomLinesLayer.tsx @@ -0,0 +1,80 @@ +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useAnimatedPath } from '@nivo/core'; +import { + type ComputedSeries, + type LineCustomSvgLayerProps, + type LineSeries, +} from '@nivo/line'; +import { animated } from '@react-spring/web'; +import { isDefined } from 'twenty-shared/utils'; + +type CustomLinesLayerProps = { + series: readonly ComputedSeries[]; + lineGenerator: LineCustomSvgLayerProps['lineGenerator']; + lineWidth: number; +}; + +type AnimatedLinePathProps = { + id: string; + path: string; + color: string; + lineWidth: number; +}; + +const AnimatedLinePath = ({ + id, + path, + color, + lineWidth, +}: AnimatedLinePathProps) => { + const animatedPath = useAnimatedPath(path); + + const highlightedLegendId = useRecoilComponentValue( + graphWidgetHighlightedLegendIdComponentState, + ); + + const isDimmed = isDefined(highlightedLegendId) && highlightedLegendId !== id; + + return ( + + ); +}; + +export const CustomLinesLayer = ({ + series, + lineGenerator, + lineWidth, +}: CustomLinesLayerProps) => { + return ( + + {[...series].reverse().map((seriesItem) => { + const path = lineGenerator( + seriesItem.data.map((point) => point.position), + ); + + if (!isDefined(path)) { + return null; + } + + return ( + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer.tsx index 0cbe2ceee4..6c25968af4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer.tsx @@ -92,7 +92,7 @@ export const CustomStackedAreasLayer = ({ {paths.map(({ id, path, fillId }) => ( - + ))} ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx index 10dd7f2778..409c3acef5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx @@ -5,6 +5,7 @@ import { CustomCrosshairLayer, type SliceHoverData, } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer'; +import { CustomLinesLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomLinesLayer'; import { CustomPointLabelsLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomPointLabelsLayer'; import { CustomStackedAreasLayer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/CustomStackedAreasLayer'; import { GraphLineChartTooltip } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip'; @@ -45,6 +46,7 @@ import { useDebouncedCallback } from 'use-debounce'; type CrosshairLayerProps = LineCustomSvgLayerProps; type PointLabelsLayerProps = LineCustomSvgLayerProps; type StackedAreasLayerProps = LineCustomSvgLayerProps; +type LinesLayerProps = LineCustomSvgLayerProps; type NoDataLayerWrapperProps = LineCustomSvgLayerProps; const LINE_CHART_DEFAULT_TICK_COUNT = 5; @@ -108,10 +110,19 @@ export const GraphWidgetLineChart = ({ customFormatter, }; - const calculatedValueRange = calculateValueRangeFromLineChartSeries(data); + const { enrichedSeries, nivoData, colors, legendItems, visibleData } = + useLineChartData({ + data, + colorRegistry, + id, + }); + + const calculatedValueRange = + calculateValueRangeFromLineChartSeries(visibleData); const hasNoData = - data.length === 0 || data.every((series) => series.data.length === 0); + visibleData.length === 0 || + visibleData.every((series) => series.data.length === 0); const { effectiveMinimumValue, effectiveMaximumValue } = computeEffectiveValueRange({ @@ -121,12 +132,6 @@ export const GraphWidgetLineChart = ({ rangeMax, }); - const { enrichedSeries, nivoData, colors, legendItems } = useLineChartData({ - data, - colorRegistry, - id, - }); - const hasClickableItems = isDefined(onSliceClick); const setActiveLineTooltip = useSetRecoilComponentState( @@ -235,6 +240,20 @@ export const GraphWidgetLineChart = ({ ); }; + const LinesLayer = (layerProps: LinesLayerProps) => { + if (hasNoData) { + return null; + } + + return ( + + ); + }; + const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => ( - + 0} + items={legendItems} + /> ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/LineAnimatedAreaPath.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/LineAnimatedAreaPath.tsx index 725d744e94..2b6d8ce233 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/LineAnimatedAreaPath.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/LineAnimatedAreaPath.tsx @@ -1,23 +1,36 @@ -import { useMotionConfig } from '@nivo/core'; -import { animated, useSpring } from '@react-spring/web'; +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useAnimatedPath } from '@nivo/core'; +import { animated } from '@react-spring/web'; +import { isDefined } from 'twenty-shared/utils'; type LineAnimatedAreaPathProps = { + id: string; path: string; fillId: string; }; export const LineAnimatedAreaPath = ({ + id, path, fillId, }: LineAnimatedAreaPathProps) => { - const { animate, config: motionConfig } = useMotionConfig(); - const spring = useSpring({ - d: path, - config: motionConfig, - immediate: !animate, - }); + const animatedPath = useAnimatedPath(path); + + const highlightedLegendId = useRecoilComponentValue( + graphWidgetHighlightedLegendIdComponentState, + ); + + const isDimmed = isDefined(highlightedLegendId) && highlightedLegendId !== id; return ( - + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts index c12a6e123c..8976cec4f4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts @@ -4,7 +4,20 @@ import { renderHook } from '@testing-library/react'; import { useLineChartData } from '../useLineChartData'; +const mockUseRecoilComponentValue = jest.fn(); +jest.mock( + '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue', + () => ({ + useRecoilComponentValue: () => mockUseRecoilComponentValue(), + }), +); + describe('useLineChartData', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRecoilComponentValue.mockReturnValue([]); + }); + const mockColorRegistry: GraphColorRegistry = { red: { name: 'red', @@ -171,4 +184,81 @@ describe('useLineChartData', () => { expect(result.current.enrichedSeries[0].label).toBe('series1'); }); + + it('should filter visible data based on hidden legend ids', () => { + mockUseRecoilComponentValue.mockReturnValue(['series2']); + + const { result } = renderHook(() => + useLineChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + }), + ); + + expect(result.current.visibleData).toHaveLength(1); + expect(result.current.visibleData[0].id).toBe('series1'); + expect(result.current.enrichedSeries).toHaveLength(1); + expect(result.current.nivoData).toHaveLength(1); + }); + + it('should maintain colors after filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['series1']); + + const { result } = renderHook(() => + useLineChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + }), + ); + + expect(result.current.enrichedSeries[0].colorScheme.name).toBe('blue'); + }); + + it('should keep all items in legend even when filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['series1']); + + const { result } = renderHook(() => + useLineChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + }), + ); + + expect(result.current.visibleData).toHaveLength(1); + expect(result.current.legendItems).toHaveLength(2); + }); + + it('should maintain alignment between nivoData and colors when filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['series1']); + + const { result } = renderHook(() => + useLineChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + }), + ); + + expect(result.current.nivoData.length).toBe(result.current.colors.length); + expect(result.current.nivoData.length).toBe(1); + expect(result.current.nivoData[0].id).toBe('series2'); + }); + + it('should handle hidden ids that do not exist in data', () => { + mockUseRecoilComponentValue.mockReturnValue(['nonexistent', 'alsoNotReal']); + + const { result } = renderHook(() => + useLineChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + id: 'test-chart', + }), + ); + + expect(result.current.visibleData).toHaveLength(2); + expect(result.current.enrichedSeries).toHaveLength(2); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts index d5720bb561..8b54ea1a09 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts @@ -1,7 +1,10 @@ +import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { type LineSeries } from '@nivo/line'; import { useMemo } from 'react'; @@ -16,13 +19,12 @@ export const useLineChartData = ({ colorRegistry, id, }: UseLineChartDataProps) => { - return useMemo(() => { - const enrichedSeries: LineChartEnrichedSeries[] = []; - const nivoData: LineSeries[] = []; - const colors: string[] = []; - const legendItems: { id: string; label: string; color: string }[] = []; + const hiddenLegendIds = useRecoilComponentValue( + graphWidgetHiddenLegendIdsComponentState, + ); - for (const [index, series] of data.entries()) { + const allEnrichedSeries = useMemo((): LineChartEnrichedSeries[] => { + return data.map((series, index) => { const colorScheme = getColorScheme({ registry: colorRegistry, colorName: series.color, @@ -34,17 +36,36 @@ export const useLineChartData = ({ .replace(/\s+/g, '_') .replace(/[^a-zA-Z0-9_-]/g, ''); const areaFillId = `areaFill-${id}-${sanitizedSeriesId}-${index}`; - const label = series.label || series.id; + const label = series.label ?? series.id; - enrichedSeries.push({ ...series, colorScheme, areaFillId, label }); - nivoData.push({ - id: series.id, - data: series.data.map((point) => ({ x: point.x, y: point.y })), - }); - colors.push(colorScheme.solid); - legendItems.push({ id: series.id, label, color: colorScheme.solid }); - } - - return { enrichedSeries, nivoData, colors, legendItems }; + return { ...series, colorScheme, areaFillId, label }; + }); }, [data, colorRegistry, id]); + + const legendItems: GraphWidgetLegendItem[] = allEnrichedSeries.map( + (series) => ({ + id: series.id, + label: series.label, + color: series.colorScheme.solid, + }), + ); + + const visibleData = data.filter( + (series) => !hiddenLegendIds.includes(series.id), + ); + + const enrichedSeries = allEnrichedSeries.filter( + (series) => !hiddenLegendIds.includes(series.id), + ); + + const nivoData: LineSeries[] = visibleData.map((series) => ({ + id: series.id, + data: series.data.map((point) => ({ x: point.x, y: point.y })), + })); + + const colors: string[] = enrichedSeries.map( + (series) => series.colorScheme.solid, + ); + + return { enrichedSeries, nivoData, colors, legendItems, visibleData }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx new file mode 100644 index 0000000000..3f7fa24b64 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx @@ -0,0 +1,71 @@ +import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; +import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useArcsTransition } from '@nivo/arcs'; +import { type MouseEventHandler, type PieCustomLayerProps } from '@nivo/pie'; +import { animated } from '@react-spring/web'; +import { isDefined } from 'twenty-shared/utils'; + +type CustomArcsLayerProps = Pick< + PieCustomLayerProps, + 'dataWithArc' | 'arcGenerator' | 'centerX' | 'centerY' +> & { + onMouseMove?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; + onClick?: MouseEventHandler; +}; + +export const CustomArcsLayer = ({ + dataWithArc, + arcGenerator, + centerX, + centerY, + onMouseMove, + onMouseLeave, + onClick, +}: CustomArcsLayerProps) => { + const highlightedLegendId = useRecoilComponentValue( + graphWidgetHighlightedLegendIdComponentState, + ); + + const { transition, interpolate } = useArcsTransition( + [...dataWithArc], + 'innerRadius', + ); + + return ( + + {transition((style, datum) => { + const isDimmed = + isDefined(highlightedLegendId) && + String(highlightedLegendId) !== String(datum.id); + + return ( + onMouseMove(datum, event) : undefined + } + onMouseLeave={ + onMouseLeave ? (event) => onMouseLeave(datum, event) : undefined + } + onClick={onClick ? (event) => onClick(datum, event) : undefined} + /> + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx index f5868ef1bb..e4228020a7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx @@ -1,5 +1,6 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; +import { CustomArcsLayer } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer'; import { GraphPieChartTooltip } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphPieChartTooltip'; import { PieChartCenterMetric } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartCenterMetricLayer'; import { PIE_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartHoverBrightness'; @@ -13,8 +14,17 @@ import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { ResponsivePie, type ComputedDatum } from '@nivo/pie'; -import { useMemo, useRef, type MouseEvent as ReactMouseEvent } from 'react'; +import { + ResponsivePie, + type ComputedDatum, + type PieCustomLayerProps, +} from '@nivo/pie'; +import { + useCallback, + useMemo, + useRef, + type MouseEvent as ReactMouseEvent, +} from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type PieChartConfiguration } from '~/generated/graphql'; @@ -86,45 +96,67 @@ export const GraphWidgetPieChart = ({ customFormatter, }; - const { enrichedData } = usePieChartData({ + const { enrichedData, legendItems } = usePieChartData({ data, colorRegistry, }); - const handleSliceClick = (datum: ComputedDatum) => { - if (isDefined(onSliceClick)) { - onSliceClick(datum.data); - } - }; + const handleSliceMove = useCallback( + ( + datum: ComputedDatum, + event: ReactMouseEvent, + ) => { + if (!isDefined(containerRef.current)) return; - const handleSliceMove = ( - datum: ComputedDatum, - event: ReactMouseEvent, - ) => { - if (!isDefined(containerRef.current)) return; - - const containerRect = containerRef.current.getBoundingClientRect(); - setActivePieTooltip({ - datum, - offsetLeft: event.clientX - containerRect.left, - offsetTop: event.clientY - containerRect.top, - }); - }; - - const handleSliceLeave = () => { - setActivePieTooltip(null); - }; - - const hasNoData = useMemo( - () => data.length === 0 || data.every((item) => item.value === 0), - [data], + const containerRect = containerRef.current.getBoundingClientRect(); + setActivePieTooltip({ + datum, + offsetLeft: event.clientX - containerRect.left, + offsetTop: event.clientY - containerRect.top, + }); + }, + [setActivePieTooltip], ); - const chartData = hasNoData ? emptyStateData : data; + const handleSliceLeave = useCallback(() => { + setActivePieTooltip(null); + }, [setActivePieTooltip]); + + const hasNoData = useMemo( + () => + enrichedData.length === 0 || + enrichedData.every((item) => item.value === 0), + [enrichedData], + ); + + const chartData = hasNoData ? emptyStateData : enrichedData; const chartColors = hasNoData ? [theme.background.tertiary] : enrichedData.map((item) => item.colorScheme.solid); + const ArcsLayer = useCallback( + (props: PieCustomLayerProps) => ( + { + if (isDefined(onSliceClick)) { + onSliceClick(datum.data); + } + } + } + /> + ), + [hasNoData, handleSliceMove, handleSliceLeave, onSliceClick], + ); + return ( null} - onClick={hasNoData ? undefined : (datum) => handleSliceClick(datum)} - onMouseMove={hasNoData ? undefined : handleSliceMove} - onMouseLeave={hasNoData ? undefined : handleSliceLeave} - layers={['arcs', 'arcLinkLabels']} + layers={[ArcsLayer, 'arcLinkLabels']} arcLinkLabel={(datum: ComputedDatum) => { const formattedValue = getPieChartFormattedValue({ datum, @@ -186,12 +214,8 @@ export const GraphWidgetPieChart = ({ onSliceClick={onSliceClick} /> ({ - id: item.id, - label: item.id, - color: item.colorScheme.solid, - }))} + show={showLegend && data.length > 0} + items={legendItems} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts index 3ca02ac3d5..1e65a02b27 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts @@ -1,12 +1,20 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; -import { type DatumId } from '@nivo/pie'; import { renderHook } from '@testing-library/react'; import { usePieChartData } from '../usePieChartData'; +const mockUseRecoilComponentValue = jest.fn(); +jest.mock( + '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue', + () => ({ + useRecoilComponentValue: () => mockUseRecoilComponentValue(), + }), +); + describe('usePieChartData', () => { beforeEach(() => { jest.clearAllMocks(); + mockUseRecoilComponentValue.mockReturnValue([]); }); const mockColorRegistry: GraphColorRegistry = { @@ -109,20 +117,91 @@ describe('usePieChartData', () => { expect(result.current.enrichedData[1].colorScheme.name).toBe('blue'); }); - it('should memoize calculations', () => { - const { result, rerender } = renderHook( - () => - usePieChartData({ - data: mockData, - colorRegistry: mockColorRegistry, - }), - { initialProps: { hoveredSliceId: null as DatumId | null } }, + it('should return legend items from all data', () => { + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), ); - const firstEnrichedData = result.current.enrichedData; + expect(result.current.legendItems).toHaveLength(3); + expect(result.current.legendItems[0]).toMatchObject({ + id: 'item1', + label: 'item1', + color: 'redSolid', + }); + }); - rerender({ hoveredSliceId: null as DatumId | null }); + it('should filter enriched data based on hidden legend ids', () => { + mockUseRecoilComponentValue.mockReturnValue(['item2']); - expect(result.current.enrichedData).toBe(firstEnrichedData); + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedData).toHaveLength(2); + expect(result.current.enrichedData.map((d) => d.id)).toEqual([ + 'item1', + 'item3', + ]); + }); + + it('should maintain colors after filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['item1']); + + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedData[0].colorScheme.name).toBe('blue'); + }); + + it('should keep all items in legend even when filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['item1', 'item2']); + + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedData).toHaveLength(1); + expect(result.current.legendItems).toHaveLength(3); + }); + + it('should preserve original percentages after filtering', () => { + mockUseRecoilComponentValue.mockReturnValue(['item2']); + + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedData).toHaveLength(2); + expect(result.current.enrichedData[0].percentage).toBe(30); + expect(result.current.enrichedData[1].percentage).toBe(20); + }); + + it('should handle hidden ids that do not exist in data', () => { + mockUseRecoilComponentValue.mockReturnValue(['nonexistent', 'alsoNotReal']); + + const { result } = renderHook(() => + usePieChartData({ + data: mockData, + colorRegistry: mockColorRegistry, + }), + ); + + expect(result.current.enrichedData).toHaveLength(3); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts index 46d24914ff..3fa3b885db 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts @@ -1,8 +1,11 @@ +import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage'; +import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useMemo } from 'react'; type UsePieChartDataProps = { @@ -14,7 +17,11 @@ export const usePieChartData = ({ data, colorRegistry, }: UsePieChartDataProps) => { - const enrichedData = useMemo((): PieChartEnrichedData[] => { + const hiddenLegendIds = useRecoilComponentValue( + graphWidgetHiddenLegendIdsComponentState, + ); + + const allEnrichedData = useMemo((): PieChartEnrichedData[] => { const totalValue = data.reduce((sum, item) => sum + item.value, 0); return data.map((item, index) => { @@ -35,6 +42,16 @@ export const usePieChartData = ({ }); }, [data, colorRegistry]); + const legendItems: GraphWidgetLegendItem[] = allEnrichedData.map((item) => ({ + id: item.id, + label: String(item.id), + color: item.colorScheme.solid, + })); + + const enrichedData = allEnrichedData.filter( + (item) => !hiddenLegendIds.includes(item.id), + ); + const enrichedDataMap = useMemo( () => new Map(enrichedData.map((item) => [item.id, item])), [enrichedData], @@ -43,5 +60,6 @@ export const usePieChartData = ({ return { enrichedData, enrichedDataMap, + legendItems, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useLegendItemToggle.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useLegendItemToggle.ts new file mode 100644 index 0000000000..27e034fe30 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useLegendItemToggle.ts @@ -0,0 +1,47 @@ +import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useCallback } from 'react'; + +type UseLegendItemToggleProps = { + itemIds: string[]; + isInteractive: boolean; +}; + +export const useLegendItemToggle = ({ + itemIds, + isInteractive, +}: UseLegendItemToggleProps) => { + const setHiddenLegendIds = useSetRecoilComponentState( + graphWidgetHiddenLegendIdsComponentState, + ); + + const toggleLegendItem = useCallback( + (itemId: string) => { + if (!isInteractive) return; + + setHiddenLegendIds((previousHiddenIds) => { + const hasStaleIds = previousHiddenIds.some( + (id) => !itemIds.includes(id), + ); + + const validHiddenIds = hasStaleIds ? [] : previousHiddenIds; + + const isCurrentlyHidden = validHiddenIds.includes(itemId); + + if (isCurrentlyHidden) { + return validHiddenIds.filter((id) => id !== itemId); + } + + const visibleCount = itemIds.length - validHiddenIds.length; + if (visibleCount <= 1) { + return validHiddenIds; + } + + return [...validHiddenIds, itemId]; + }); + }, + [isInteractive, itemIds, setHiddenLegendIds], + ); + + return { toggleLegendItem }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState.ts new file mode 100644 index 0000000000..c6baafa0b7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState.ts @@ -0,0 +1,10 @@ +import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const graphWidgetHiddenLegendIdsComponentState = createComponentState< + string[] +>({ + key: 'graphWidgetHiddenLegendIdsComponentState', + defaultValue: [], + componentInstanceContext: GraphWidgetComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState.ts new file mode 100644 index 0000000000..f31b87a27a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState.ts @@ -0,0 +1,9 @@ +import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const graphWidgetHighlightedLegendIdComponentState = + createComponentState({ + key: 'graphWidgetHighlightedLegendIdComponentState', + defaultValue: null, + componentInstanceContext: GraphWidgetComponentInstanceContext, + });