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,
+ });