nitin
2025-12-15 22:58:51 +05:30
committed by GitHub
parent 289e8bf1d4
commit 9c3cd959ba
24 changed files with 923 additions and 186 deletions
@@ -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 (
<RecoilRoot initializeState={initializeState}>
<GraphWidgetComponentInstanceContext.Provider value={{ instanceId }}>
{children}
</GraphWidgetComponentInstanceContext.Provider>
<PageLayoutComponentInstanceContext.Provider
value={{ instanceId: pageLayoutInstanceId }}
>
<GraphWidgetComponentInstanceContext.Provider value={{ instanceId }}>
{children}
</GraphWidgetComponentInstanceContext.Provider>
</PageLayoutComponentInstanceContext.Provider>
</RecoilRoot>
);
};
@@ -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) => (
<StyledLegendItem
key={item.id}
canShrink={!needsPagination}
>
<GraphWidgetLegendDot color={item.color} />
<StyledLegendLabel fixedWidth={needsPagination}>
<OverflowingTextWithTooltip text={item.label} />
</StyledLegendLabel>
</StyledLegendItem>
))}
{visibleItems.map((item) => {
const isHidden = hiddenLegendIds.includes(item.id);
return (
<StyledLegendItem
key={item.id}
canShrink={!needsPagination}
isHidden={isHidden}
isInteractive={isInteractive}
onClick={() => toggleLegendItem(item.id)}
onMouseEnter={() => handleLegendItemMouseEnter(item.id)}
onMouseLeave={handleLegendItemMouseLeave}
>
<StyledLegendDot
color={item.color}
isHidden={isHidden}
/>
<StyledLegendLabel
fixedWidth={needsPagination}
isHidden={isHidden}
>
<OverflowingTextWithTooltip text={item.label} />
</StyledLegendLabel>
</StyledLegendItem>
);
})}
</StyledItemsWrapper>
</AnimatePresence>
</StyledAnimationClipContainer>
@@ -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 <StyledDot color={color} />;
export const GraphWidgetLegendDot = ({
color,
className,
}: GraphWidgetLegendDotProps) => {
return <StyledDot color={color} className={className} />;
};
@@ -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<typeof GraphWidgetGaugeChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
component: GraphWidgetGaugeChart,
decorators: [ComponentDecorator],
decorators: [
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
},
@@ -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<typeof GraphWidgetLegend> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLegend',
component: GraphWidgetLegend,
decorators: [ComponentDecorator],
decorators: [
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
},
@@ -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<typeof GraphWidgetPieChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
component: GraphWidgetPieChart,
decorators: [
(Story) => (
<GraphWidgetComponentInstanceContext.Provider
value={{ instanceId: GRAPH_WIDGET_TEST_INSTANCE_ID }}
>
<Story />
</GraphWidgetComponentInstanceContext.Provider>
),
ComponentDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
RootDecorator,
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
ComponentDecorator,
],
parameters: {
layout: 'centered',
@@ -369,17 +364,14 @@ export const Storage: Story = {
export const Catalog: Story = {
decorators: [
(Story) => (
<GraphWidgetComponentInstanceContext.Provider
value={{ instanceId: GRAPH_WIDGET_TEST_INSTANCE_ID }}
>
<Story />
</GraphWidgetComponentInstanceContext.Provider>
),
CatalogDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
RootDecorator,
(Story) => (
<GraphWidgetTestWrapper>
<Story />
</GraphWidgetTestWrapper>
),
CatalogDecorator,
],
parameters: {
catalog: {
@@ -0,0 +1 @@
export const LEGEND_HIGHLIGHT_DIMMED_OPACITY = 0.2;
@@ -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<D extends BarDatum> = BarItemProps<D> & {
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 = <D extends BarDatum>({
layout = BarChartLayout.VERTICAL,
chartId,
}: CustomBarItemProps<D>) => {
const highlightedLegendId = useRecoilComponentValue(
graphWidgetHighlightedLegendIdComponentState,
);
const isDimmed =
isDefined(highlightedLegendId) &&
String(highlightedLegendId) !== String(barData.id);
const handleClick = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onClick?.({ color: bar.color, ...barData }, event);
@@ -197,6 +215,7 @@ export const CustomBarItem = <D extends BarDatum>({
<StyledBarRect
$isInteractive={isInteractive}
$isDimmed={isDimmed}
clipPath={shouldRoundFreeEnd ? `url(#${clipPathId})` : undefined}
width={to(finalBarWidthDimension, (value) => Math.max(value, 0))}
height={to(finalBarHeightDimension, (value) => Math.max(value, 0))}
@@ -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<BarDatum>;
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 = ({
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
keys={keys}
keys={visibleKeys}
groupMode={groupMode}
data={data}
indexBy={indexBy}
@@ -214,7 +213,7 @@ export const GraphWidgetBarChart = ({
chartId={id}
/>
),
[keys, groupMode, data, indexBy, layout, id],
[visibleKeys, groupMode, data, indexBy, layout, id],
);
const TotalsLayer = ({
@@ -280,7 +279,7 @@ export const GraphWidgetBarChart = ({
<ResponsiveBar
barComponent={BarItemWithContext}
data={data}
keys={keys}
keys={visibleKeys}
indexBy={indexBy}
margin={margins}
padding={BAR_CHART_OUTER_PADDING_RATIO}
@@ -322,7 +321,7 @@ export const GraphWidgetBarChart = ({
chartWidth,
chartHeight,
dataLength: data.length,
keysLength: keys.length,
keysLength: visibleKeys.length,
layout,
margins,
groupMode,
@@ -355,14 +354,8 @@ export const GraphWidgetBarChart = ({
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend
show={showLegend && !hasNoData}
items={enrichedKeys.map((item) => {
return {
id: item.key,
label: item.label,
color: item.colorScheme.solid,
};
})}
show={showLegend && data.length > 0 && keys.length > 0}
items={legendItems}
/>
</StyledContainer>
);
@@ -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);
});
});
@@ -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<string, BarChartSeries>(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,
};
};
@@ -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<LineSeries>[];
lineGenerator: LineCustomSvgLayerProps<LineSeries>['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 (
<animated.path
d={animatedPath}
fill="none"
stroke={color}
strokeWidth={lineWidth}
opacity={isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}
style={{ transition: 'opacity 0.15s ease-in-out' }}
/>
);
};
export const CustomLinesLayer = ({
series,
lineGenerator,
lineWidth,
}: CustomLinesLayerProps) => {
return (
<g>
{[...series].reverse().map((seriesItem) => {
const path = lineGenerator(
seriesItem.data.map((point) => point.position),
);
if (!isDefined(path)) {
return null;
}
return (
<AnimatedLinePath
key={seriesItem.id}
id={String(seriesItem.id)}
path={path}
color={seriesItem.color}
lineWidth={lineWidth}
/>
);
})}
</g>
);
};
@@ -92,7 +92,7 @@ export const CustomStackedAreasLayer = ({
<g>
<LineAreaGradientDefs enrichedSeries={enrichedSeries} />
{paths.map(({ id, path, fillId }) => (
<LineAnimatedAreaPath key={id} path={path} fillId={fillId} />
<LineAnimatedAreaPath key={id} id={id} path={path} fillId={fillId} />
))}
</g>
);
@@ -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<LineSeries>;
type PointLabelsLayerProps = LineCustomSvgLayerProps<LineSeries>;
type StackedAreasLayerProps = LineCustomSvgLayerProps<LineSeries>;
type LinesLayerProps = LineCustomSvgLayerProps<LineSeries>;
type NoDataLayerWrapperProps = LineCustomSvgLayerProps<LineSeries>;
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 (
<CustomLinesLayer
series={layerProps.series}
lineGenerator={layerProps.lineGenerator}
lineWidth={layerProps.lineWidth}
/>
);
};
const NoDataLayerWrapper = (layerProps: NoDataLayerWrapperProps) => (
<NoDataLayer
innerWidth={layerProps.innerWidth}
@@ -246,7 +265,7 @@ export const GraphWidgetLineChart = ({
const axisBottomConfig = getLineChartAxisBottomConfig(
xAxisLabel,
chartWidth,
data,
visibleData,
);
const chartMargins = {
top: LINE_CHART_MARGIN_TOP,
@@ -319,7 +338,7 @@ export const GraphWidgetLineChart = ({
'markers',
'axes',
StackedAreasLayer,
'lines',
LinesLayer,
CrosshairLayer,
'points',
PointLabelsLayer,
@@ -337,7 +356,10 @@ export const GraphWidgetLineChart = ({
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend show={showLegend && !hasNoData} items={legendItems} />
<GraphWidgetLegend
show={showLegend && data.length > 0}
items={legendItems}
/>
</StyledContainer>
);
};
@@ -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 (
<animated.path d={spring.d} fill={`url(#${fillId})`} strokeWidth={0} />
<animated.path
d={animatedPath}
fill={`url(#${fillId})`}
strokeWidth={0}
opacity={isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}
style={{ transition: 'opacity 0.15s ease-in-out' }}
/>
);
};
@@ -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);
});
});
@@ -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 };
};
@@ -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<PieChartDataItem>,
'dataWithArc' | 'arcGenerator' | 'centerX' | 'centerY'
> & {
onMouseMove?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
onMouseLeave?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
onClick?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
};
export const CustomArcsLayer = ({
dataWithArc,
arcGenerator,
centerX,
centerY,
onMouseMove,
onMouseLeave,
onClick,
}: CustomArcsLayerProps) => {
const highlightedLegendId = useRecoilComponentValue(
graphWidgetHighlightedLegendIdComponentState,
);
const { transition, interpolate } = useArcsTransition(
[...dataWithArc],
'innerRadius',
);
return (
<g transform={`translate(${centerX},${centerY})`}>
{transition((style, datum) => {
const isDimmed =
isDefined(highlightedLegendId) &&
String(highlightedLegendId) !== String(datum.id);
return (
<animated.path
key={datum.id}
d={interpolate(
style.startAngle,
style.endAngle,
style.innerRadius,
style.outerRadius,
arcGenerator,
)}
fill={datum.color}
opacity={isDimmed ? LEGEND_HIGHLIGHT_DIMMED_OPACITY : 1}
style={{
transition: 'opacity 0.15s ease-in-out',
}}
onMouseMove={
onMouseMove ? (event) => onMouseMove(datum, event) : undefined
}
onMouseLeave={
onMouseLeave ? (event) => onMouseLeave(datum, event) : undefined
}
onClick={onClick ? (event) => onClick(datum, event) : undefined}
/>
);
})}
</g>
);
};
@@ -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<PieChartDataItem>) => {
if (isDefined(onSliceClick)) {
onSliceClick(datum.data);
}
};
const handleSliceMove = useCallback(
(
datum: ComputedDatum<PieChartDataItem>,
event: ReactMouseEvent<SVGPathElement>,
) => {
if (!isDefined(containerRef.current)) return;
const handleSliceMove = (
datum: ComputedDatum<PieChartDataItem>,
event: ReactMouseEvent<SVGPathElement>,
) => {
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<PieChartDataItem>) => (
<CustomArcsLayer
dataWithArc={props.dataWithArc}
arcGenerator={props.arcGenerator}
centerX={props.centerX}
centerY={props.centerY}
onMouseMove={hasNoData ? undefined : handleSliceMove}
onMouseLeave={hasNoData ? undefined : handleSliceLeave}
onClick={
hasNoData
? undefined
: (datum) => {
if (isDefined(onSliceClick)) {
onSliceClick(datum.data);
}
}
}
/>
),
[hasNoData, handleSliceMove, handleSliceLeave, onSliceClick],
);
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
@@ -140,14 +172,10 @@ export const GraphWidgetPieChart = ({
innerRadius={0.8}
padAngle={hasNoData ? 0 : 0.4}
colors={chartColors}
borderWidth={0}
enableArcLinkLabels={showDataLabels && !hasNoData}
enableArcLabels={false}
tooltip={() => null}
onClick={hasNoData ? undefined : (datum) => handleSliceClick(datum)}
onMouseMove={hasNoData ? undefined : handleSliceMove}
onMouseLeave={hasNoData ? undefined : handleSliceLeave}
layers={['arcs', 'arcLinkLabels']}
layers={[ArcsLayer, 'arcLinkLabels']}
arcLinkLabel={(datum: ComputedDatum<PieChartDataItem>) => {
const formattedValue = getPieChartFormattedValue({
datum,
@@ -186,12 +214,8 @@ export const GraphWidgetPieChart = ({
onSliceClick={onSliceClick}
/>
<GraphWidgetLegend
show={showLegend && !hasNoData}
items={enrichedData.map((item) => ({
id: item.id,
label: item.id,
color: item.colorScheme.solid,
}))}
show={showLegend && data.length > 0}
items={legendItems}
/>
</StyledContainer>
);
@@ -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);
});
});
@@ -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,
};
};
@@ -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 };
};
@@ -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,
});
@@ -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<string | null>({
key: 'graphWidgetHighlightedLegendIdComponentState',
defaultValue: null,
componentInstanceContext: GraphWidgetComponentInstanceContext,
});