[Dashboards] Pie chart custom tooltip (#16318)
closes https://github.com/twentyhq/core-team-issues/issues/1884 and https://discord.com/channels/1130383047699738754/1443679780414427267 before - https://github.com/user-attachments/assets/db19657f-d7d3-4965-9b88-83e5829d3942 after - https://github.com/user-attachments/assets/d88ec686-0298-4b5f-a214-03e8d8169e7d
This commit is contained in:
+7
-1
@@ -17,6 +17,7 @@ type GraphWidgetFloatingTooltipProps = {
|
||||
onGraphWidgetTooltipClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
disablePointerEvents?: boolean;
|
||||
};
|
||||
|
||||
export const GraphWidgetFloatingTooltip = ({
|
||||
@@ -28,6 +29,7 @@ export const GraphWidgetFloatingTooltip = ({
|
||||
onGraphWidgetTooltipClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
disablePointerEvents = false,
|
||||
}: GraphWidgetFloatingTooltipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -44,7 +46,11 @@ export const GraphWidgetFloatingTooltip = ({
|
||||
<FloatingPortal root={boundary}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{ ...floatingStyles, zIndex: theme.lastLayerZIndex }}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: theme.lastLayerZIndex,
|
||||
pointerEvents: disablePointerEvents ? 'none' : 'auto',
|
||||
}}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
onMouseEnter={onMouseEnter}
|
||||
|
||||
+16
@@ -1,7 +1,9 @@
|
||||
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 { 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,
|
||||
@@ -34,6 +36,13 @@ 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,
|
||||
@@ -360,6 +369,13 @@ 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,
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip';
|
||||
import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState';
|
||||
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
|
||||
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
|
||||
import { getPieChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData';
|
||||
import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset';
|
||||
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type GraphPieChartTooltipProps = {
|
||||
containerId: string;
|
||||
enrichedData: PieChartEnrichedData[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
displayType?: string;
|
||||
onSliceClick?: (datum: PieChartDataItem) => void;
|
||||
};
|
||||
|
||||
export const GraphPieChartTooltip = ({
|
||||
containerId,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
onSliceClick,
|
||||
}: GraphPieChartTooltipProps) => {
|
||||
const tooltipState = useRecoilComponentValue(
|
||||
graphWidgetPieTooltipComponentState,
|
||||
);
|
||||
|
||||
if (!isDefined(tooltipState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerElement = document.getElementById(containerId);
|
||||
if (!isDefined(containerElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipData = getPieChartTooltipData({
|
||||
datum: tooltipState.datum,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
});
|
||||
|
||||
const handleTooltipClick: (() => void) | undefined = isDefined(onSliceClick)
|
||||
? () => onSliceClick(tooltipState.datum.data)
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(tooltipData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reference = createVirtualElementFromContainerOffset(
|
||||
containerElement,
|
||||
tooltipState.offsetLeft,
|
||||
tooltipState.offsetTop,
|
||||
);
|
||||
|
||||
return (
|
||||
<GraphWidgetFloatingTooltip
|
||||
reference={reference}
|
||||
boundary={containerElement}
|
||||
items={[tooltipData.tooltipItem]}
|
||||
disablePointerEvents
|
||||
onGraphWidgetTooltipClick={handleTooltipClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+43
-32
@@ -1,22 +1,20 @@
|
||||
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
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';
|
||||
import { PIE_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMargins';
|
||||
import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData';
|
||||
import { usePieChartTooltip } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip';
|
||||
import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState';
|
||||
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
|
||||
import { getPieChartFormattedValue } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
ResponsivePie,
|
||||
type ComputedDatum,
|
||||
type PieTooltipProps,
|
||||
} from '@nivo/pie';
|
||||
import { useMemo } from 'react';
|
||||
import { ResponsivePie, type ComputedDatum } from '@nivo/pie';
|
||||
import { useMemo, useRef, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type PieChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
@@ -75,6 +73,10 @@ export const GraphWidgetPieChart = ({
|
||||
}: GraphWidgetPieChartProps) => {
|
||||
const theme = useTheme();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const setActivePieTooltip = useSetRecoilComponentState(
|
||||
graphWidgetPieTooltipComponentState,
|
||||
);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
@@ -89,32 +91,28 @@ export const GraphWidgetPieChart = ({
|
||||
colorRegistry,
|
||||
});
|
||||
|
||||
const { createTooltipData } = usePieChartTooltip({
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
});
|
||||
|
||||
const handleSliceClick = (datum: ComputedDatum<PieChartDataItem>) => {
|
||||
if (isDefined(onSliceClick)) {
|
||||
onSliceClick(datum.data);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltip = ({ datum }: PieTooltipProps<PieChartDataItem>) => {
|
||||
const tooltipData = createTooltipData(datum);
|
||||
if (!isDefined(tooltipData)) return null;
|
||||
const handleSliceMove = (
|
||||
datum: ComputedDatum<PieChartDataItem>,
|
||||
event: ReactMouseEvent<SVGPathElement>,
|
||||
) => {
|
||||
if (!isDefined(containerRef.current)) return;
|
||||
|
||||
const handleTooltipClick: (() => void) | undefined = isDefined(onSliceClick)
|
||||
? () => handleSliceClick(datum)
|
||||
: undefined;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setActivePieTooltip({
|
||||
datum,
|
||||
offsetLeft: event.clientX - containerRect.left,
|
||||
offsetTop: event.clientY - containerRect.top,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
onGraphWidgetTooltipClick={handleTooltipClick}
|
||||
/>
|
||||
);
|
||||
const handleSliceLeave = () => {
|
||||
setActivePieTooltip(null);
|
||||
};
|
||||
|
||||
const hasNoData = useMemo(
|
||||
@@ -130,8 +128,10 @@ export const GraphWidgetPieChart = ({
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<GraphWidgetChartContainer
|
||||
ref={containerRef}
|
||||
$isClickable={!hasNoData && isDefined(onSliceClick)}
|
||||
$cursorSelector="svg g path"
|
||||
onMouseLeave={handleSliceLeave}
|
||||
>
|
||||
<StyledPieChartWrapper preventHover={hasNoData}>
|
||||
<ResponsivePie
|
||||
@@ -143,15 +143,19 @@ export const GraphWidgetPieChart = ({
|
||||
borderWidth={0}
|
||||
enableArcLinkLabels={showDataLabels && !hasNoData}
|
||||
enableArcLabels={false}
|
||||
tooltip={hasNoData ? () => null : renderTooltip}
|
||||
tooltip={() => null}
|
||||
onClick={hasNoData ? undefined : (datum) => handleSliceClick(datum)}
|
||||
onMouseMove={hasNoData ? undefined : handleSliceMove}
|
||||
onMouseLeave={hasNoData ? undefined : handleSliceLeave}
|
||||
layers={['arcs', 'arcLinkLabels']}
|
||||
arcLinkLabel={(datum: ComputedDatum<PieChartDataItem>) => {
|
||||
const tooltipData = createTooltipData(datum);
|
||||
return (
|
||||
tooltipData?.tooltipItem.formattedValue ||
|
||||
datum.data.value.toString()
|
||||
);
|
||||
const formattedValue = getPieChartFormattedValue({
|
||||
datum,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
});
|
||||
return formattedValue ?? datum.data.value.toString();
|
||||
}}
|
||||
arcLinkLabelsDiagonalLength={10}
|
||||
arcLinkLabelsStraightLength={10}
|
||||
@@ -173,6 +177,13 @@ export const GraphWidgetPieChart = ({
|
||||
/>
|
||||
</StyledPieChartWrapper>
|
||||
</GraphWidgetChartContainer>
|
||||
<GraphPieChartTooltip
|
||||
containerId={id}
|
||||
enrichedData={enrichedData}
|
||||
formatOptions={formatOptions}
|
||||
displayType={displayType}
|
||||
onSliceClick={onSliceClick}
|
||||
/>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend && !hasNoData}
|
||||
items={enrichedData.map((item) => ({
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { type ComputedDatum } from '@nivo/pie';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UsePieChartTooltipProps = {
|
||||
enrichedData: PieChartEnrichedData[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
displayType?: string;
|
||||
};
|
||||
|
||||
export const usePieChartTooltip = ({
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
}: UsePieChartTooltipProps) => {
|
||||
const createTooltipData = (
|
||||
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
|
||||
) => {
|
||||
const item = enrichedData.find(
|
||||
(enrichedDataItem) => enrichedDataItem.id === datum.id,
|
||||
);
|
||||
if (!isDefined(item)) return null;
|
||||
|
||||
const formattedValue =
|
||||
displayType === 'percentage'
|
||||
? formatGraphValue(item.percentage / 100, formatOptions)
|
||||
: `${formatGraphValue(item.value, formatOptions)} (${item.percentage.toFixed(1)}%)`;
|
||||
|
||||
return {
|
||||
tooltipItem: {
|
||||
key: item.id,
|
||||
label: item.id,
|
||||
formattedValue,
|
||||
value: item.value,
|
||||
dotColor: item.colorScheme.solid,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createTooltipData,
|
||||
};
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { type ComputedDatum } from '@nivo/pie';
|
||||
import { type PieChartDataItem } from '../types/PieChartDataItem';
|
||||
|
||||
export const graphWidgetPieTooltipComponentState = createComponentState<{
|
||||
datum: ComputedDatum<PieChartDataItem>;
|
||||
offsetLeft: number;
|
||||
offsetTop: number;
|
||||
} | null>({
|
||||
key: 'graphWidgetPieTooltipComponentState',
|
||||
defaultValue: null,
|
||||
componentInstanceContext: GraphWidgetComponentInstanceContext,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { type ComputedDatum } from '@nivo/pie';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type PieChartDataItem } from '../types/PieChartDataItem';
|
||||
import { type PieChartEnrichedData } from '../types/PieChartEnrichedData';
|
||||
|
||||
type GetPieChartFormattedValueParams = {
|
||||
datum: ComputedDatum<PieChartDataItem>;
|
||||
enrichedData: PieChartEnrichedData[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
displayType?: string;
|
||||
};
|
||||
|
||||
export const getPieChartFormattedValue = ({
|
||||
datum,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
}: GetPieChartFormattedValueParams): string | null => {
|
||||
const item = enrichedData.find(
|
||||
(enrichedDataItem) => enrichedDataItem.id === datum.id,
|
||||
);
|
||||
if (!isDefined(item)) return null;
|
||||
|
||||
return displayType === 'percentage'
|
||||
? formatGraphValue(item.percentage / 100, formatOptions)
|
||||
: `${formatGraphValue(item.value, formatOptions)} (${item.percentage.toFixed(1)}%)`;
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
|
||||
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
|
||||
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { type ComputedDatum } from '@nivo/pie';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getPieChartFormattedValue } from './getPieChartFormattedValue';
|
||||
|
||||
type GetPieChartTooltipDataParams = {
|
||||
datum: ComputedDatum<PieChartDataItem>;
|
||||
enrichedData: PieChartEnrichedData[];
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
displayType?: string;
|
||||
};
|
||||
|
||||
export const getPieChartTooltipData = ({
|
||||
datum,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
}: GetPieChartTooltipDataParams): {
|
||||
tooltipItem: GraphWidgetTooltipItem;
|
||||
} | null => {
|
||||
const item = enrichedData.find(
|
||||
(enrichedDataItem) => enrichedDataItem.id === datum.id,
|
||||
);
|
||||
if (!isDefined(item)) return null;
|
||||
|
||||
const formattedValue = getPieChartFormattedValue({
|
||||
datum,
|
||||
enrichedData,
|
||||
formatOptions,
|
||||
displayType,
|
||||
});
|
||||
if (!isDefined(formattedValue)) return null;
|
||||
|
||||
return {
|
||||
tooltipItem: {
|
||||
key: item.id,
|
||||
label: item.id,
|
||||
formattedValue,
|
||||
value: item.value,
|
||||
dotColor: item.colorScheme.solid,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user