[Dashboards] add legend hover highlight for graph widgets (#16551)
closes https://github.com/twentyhq/core-team-issues/issues/1959 line - https://github.com/user-attachments/assets/3fcefd47-c065-488c-a9e1-e820e8a03349 bar - https://github.com/user-attachments/assets/a138e376-9304-46b9-b6ba-c642f7b85ae2 pie - https://github.com/user-attachments/assets/cc008d0e-ec7f-47a3-ac14-5fef56426f6a onClick - https://github.com/user-attachments/assets/94c1791d-b820-449c-81f9-c3d10361b694
This commit is contained in:
+15
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+94
-13
@@ -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>
|
||||
|
||||
+6
-2
@@ -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} />;
|
||||
};
|
||||
|
||||
+10
-2
@@ -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',
|
||||
},
|
||||
|
||||
+9
-1
@@ -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',
|
||||
},
|
||||
|
||||
+13
-21
@@ -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: {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LEGEND_HIGHLIGHT_DIMMED_OPACITY = 0.2;
|
||||
+21
-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))}
|
||||
|
||||
+20
-27
@@ -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>
|
||||
);
|
||||
|
||||
+115
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+43
-23
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+80
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
|
||||
+33
-11
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-9
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+90
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-17
@@ -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 };
|
||||
};
|
||||
|
||||
+71
@@ -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>
|
||||
);
|
||||
};
|
||||
+65
-41
@@ -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>
|
||||
);
|
||||
|
||||
+91
-12
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+47
@@ -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 };
|
||||
};
|
||||
+10
@@ -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,
|
||||
});
|
||||
+9
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user