[BREAKING CHANGE] fix chart cache collisions with key-based data plumbing (#21743)

closes
https://discord.com/channels/1130383047699738754/1514946035317997709

This fixes Apollo cache collisions for pie slices and line series by
keeping chart bucket identity as key end-to-end, matching how bar chart
already works.


What changed -- 

- Renamed pie/line chart response identity from id to key in the chart
data path.
- Kept key through frontend chart hooks, types, stories, and
tooltip/drilldown logic.
- Only adapt key to id at actual external boundaries like Nivo and
GraphWidgetLegend.
- Added/updated tests covering cache normalization and chart data
behavior.


before - 

<img width="2600" height="844" alt="CleanShot 2026-06-17 at 20 17 14@2x"
src="https://github.com/user-attachments/assets/b9ee83e9-db4b-423e-8668-a7beb4c4c62e"
/>

after - 

<img width="2614" height="800" alt="CleanShot 2026-06-17 at 20 16 17@2x"
src="https://github.com/user-attachments/assets/674a5417-ffc2-441d-9484-e1126438254c"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21743?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-17 21:43:27 +05:30
committed by GitHub
parent 02a3a3c47c
commit 177afde866
34 changed files with 313 additions and 209 deletions
@@ -2716,7 +2716,7 @@ type LineChartDataPoint {
}
type LineChartSeries {
id: String!
key: String!
label: String!
data: [LineChartDataPoint!]!
}
@@ -2732,7 +2732,7 @@ type LineChartData {
}
type PieChartDataItem {
id: String!
key: String!
value: Float!
}
@@ -2372,7 +2372,7 @@ export interface LineChartDataPoint {
}
export interface LineChartSeries {
id: Scalars['String']
key: Scalars['String']
label: Scalars['String']
data: LineChartDataPoint[]
__typename: 'LineChartSeries'
@@ -2390,7 +2390,7 @@ export interface LineChartData {
}
export interface PieChartDataItem {
id: Scalars['String']
key: Scalars['String']
value: Scalars['Float']
__typename: 'PieChartDataItem'
}
@@ -5484,7 +5484,7 @@ export interface LineChartDataPointGenqlSelection{
}
export interface LineChartSeriesGenqlSelection{
id?: boolean | number
key?: boolean | number
label?: boolean | number
data?: LineChartDataPointGenqlSelection
__typename?: boolean | number
@@ -5504,7 +5504,7 @@ export interface LineChartDataGenqlSelection{
}
export interface PieChartDataItemGenqlSelection{
id?: boolean | number
key?: boolean | number
value?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
@@ -5366,7 +5366,7 @@ export default {
]
},
"LineChartSeries": {
"id": [
"key": [
1
],
"label": [
@@ -5406,7 +5406,7 @@ export default {
]
},
"PieChartDataItem": {
"id": [
"key": [
1
],
"value": [
File diff suppressed because one or more lines are too long
@@ -121,13 +121,13 @@ export const Default: Story = {
id: 'line-chart-default',
data: [
{
id: 'series1',
key: 'series1',
label: 'Revenue',
color: 'blue',
data: generateLinearData(12),
},
{
id: 'series2',
key: 'series2',
label: 'Profit',
color: 'turquoise',
data: generateLinearData(12),
@@ -149,13 +149,13 @@ export const WithArea: Story = {
id: 'line-chart-area',
data: [
{
id: 'sales',
key: 'sales',
label: 'Sales',
color: 'purple',
data: generateLinearData(12),
},
{
id: 'costs',
key: 'costs',
label: 'Costs',
color: 'orange',
data: generateLinearData(12),
@@ -175,19 +175,19 @@ export const StackedArea: Story = {
id: 'line-chart-stacked',
data: [
{
id: 'product-a',
key: 'product-a',
label: 'Product A',
color: 'blue',
data: generateLinearData(8),
},
{
id: 'product-b',
key: 'product-b',
label: 'Product B',
color: 'turquoise',
data: generateLinearData(8),
},
{
id: 'product-c',
key: 'product-c',
label: 'Product C',
color: 'purple',
data: generateLinearData(8),
@@ -209,7 +209,7 @@ export const StackedWithNegativeValues: Story = {
id: 'line-chart-stacked-negative',
data: [
{
id: 'series-positive',
key: 'series-positive',
label: 'Positive Series',
color: 'blue',
data: [
@@ -219,7 +219,7 @@ export const StackedWithNegativeValues: Story = {
],
},
{
id: 'series-negative',
key: 'series-negative',
label: 'Negative Series',
color: 'red',
data: [
@@ -245,7 +245,7 @@ export const StepChart: Story = {
id: 'line-chart-step',
data: [
{
id: 'inventory',
key: 'inventory',
label: 'Inventory Level',
color: 'orange',
data: generateLinearData(10),
@@ -265,7 +265,7 @@ export const LogScaleDemo: Story = {
id: 'line-chart-log-scale',
data: [
{
id: 'exponential',
key: 'exponential',
label: 'Exponential Growth',
color: 'purple',
data: [
@@ -278,7 +278,7 @@ export const LogScaleDemo: Story = {
],
},
{
id: 'linear',
key: 'linear',
label: 'Linear Growth',
color: 'turquoise',
data: [
@@ -305,7 +305,7 @@ export const WithNullValues: Story = {
id: 'line-chart-nulls',
data: [
{
id: 'incomplete',
key: 'incomplete',
label: 'With Gaps',
color: 'blue',
data: [
@@ -333,19 +333,19 @@ export const MultiSeriesMixed: Story = {
id: 'line-chart-mixed',
data: [
{
id: 'actual',
key: 'actual',
label: 'Actual',
color: 'blue',
data: generateLinearData(12),
},
{
id: 'forecast',
key: 'forecast',
label: 'Forecast',
color: 'purple',
data: generateLinearData(12),
},
{
id: 'target',
key: 'target',
label: 'Target',
color: 'orange',
data: generateLinearData(12).map((dataPoint) => ({
@@ -368,7 +368,7 @@ export const OverlappingGradientBlend: Story = {
id: 'line-chart-blend',
data: [
{
id: 'red-series',
key: 'red-series',
label: 'Red Wave',
color: 'red',
data: [
@@ -383,7 +383,7 @@ export const OverlappingGradientBlend: Story = {
],
},
{
id: 'blue-series',
key: 'blue-series',
label: 'Blue Wave',
color: 'blue',
data: [
@@ -398,7 +398,7 @@ export const OverlappingGradientBlend: Story = {
],
},
{
id: 'green-series',
key: 'green-series',
label: 'Green Wave',
color: 'turquoise',
data: [
@@ -427,7 +427,7 @@ export const HighContrastOverlap: Story = {
id: 'line-chart-contrast',
data: [
{
id: 'yellow-series',
key: 'yellow-series',
label: 'Yellow',
color: 'yellow',
data: [
@@ -441,7 +441,7 @@ export const HighContrastOverlap: Story = {
],
},
{
id: 'purple-series',
key: 'purple-series',
label: 'Purple',
color: 'purple',
data: [
@@ -469,7 +469,7 @@ export const CurveComparison: Story = {
id: 'line-chart-curves',
data: [
{
id: 'dataset',
key: 'dataset',
label: 'Same Data',
color: 'orange',
data: [
@@ -497,7 +497,7 @@ export const StepInterpolations: Story = {
id: 'line-chart-steps',
data: [
{
id: 'step-normal',
key: 'step-normal',
label: 'Step',
color: 'blue',
data: [
@@ -510,7 +510,7 @@ export const StepInterpolations: Story = {
],
},
{
id: 'step-before',
key: 'step-before',
label: 'Step Before',
color: 'purple',
data: [
@@ -523,7 +523,7 @@ export const StepInterpolations: Story = {
],
},
{
id: 'step-after',
key: 'step-after',
label: 'Step After',
color: 'turquoise',
data: [
@@ -550,7 +550,7 @@ export const NaturalVsMonotone: Story = {
id: 'line-chart-smooth',
data: [
{
id: 'natural',
key: 'natural',
label: 'Natural Curve',
color: 'pink',
data: [
@@ -564,7 +564,7 @@ export const NaturalVsMonotone: Story = {
],
},
{
id: 'monotone',
key: 'monotone',
label: 'Monotone X',
color: 'orange',
data: [
@@ -592,7 +592,7 @@ export const SliceTooltipDemo: Story = {
id: 'line-chart-slice-tooltip',
data: [
{
id: 'revenue',
key: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
@@ -605,7 +605,7 @@ export const SliceTooltipDemo: Story = {
],
},
{
id: 'costs',
key: 'costs',
label: 'Costs',
color: 'red',
data: [
@@ -618,7 +618,7 @@ export const SliceTooltipDemo: Story = {
],
},
{
id: 'profit',
key: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
@@ -646,7 +646,7 @@ export const PointTooltipDemo: Story = {
id: 'line-chart-point-tooltip',
data: [
{
id: 'revenue',
key: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
@@ -659,7 +659,7 @@ export const PointTooltipDemo: Story = {
],
},
{
id: 'costs',
key: 'costs',
label: 'Costs',
color: 'red',
data: [
@@ -672,7 +672,7 @@ export const PointTooltipDemo: Story = {
],
},
{
id: 'profit',
key: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
@@ -700,13 +700,13 @@ export const Catalog: Story = {
id: 'line-chart-catalog',
data: [
{
id: 'series1',
key: 'series1',
label: 'Series 1',
color: 'blue',
data: generateLinearData(8),
},
{
id: 'series2',
key: 'series2',
label: 'Series 2',
color: 'purple',
data: generateLinearData(8),
@@ -736,7 +736,7 @@ export const Catalog: Story = {
props: (color: string) => ({
data: [
{
id: 'series',
key: 'series',
label: `${color} Series`,
color,
data: generateLinearData(8),
@@ -97,11 +97,11 @@ const Container = ({ children }: { children: React.ReactNode }) => (
export const Default: Story = {
args: {
data: [
{ id: 'Qualified', value: 35 },
{ id: 'Contacted', value: 25 },
{ id: 'Unqualified', value: 20 },
{ id: 'Proposal', value: 15 },
{ id: 'Negotiation', value: 5 },
{ key: 'Qualified', value: 35 },
{ key: 'Contacted', value: 25 },
{ key: 'Unqualified', value: 20 },
{ key: 'Proposal', value: 15 },
{ key: 'Negotiation', value: 5 },
],
showLegend: true,
id: 'pie-chart-default',
@@ -127,9 +127,9 @@ export const Default: Story = {
export const WithCenterMetric: Story = {
args: {
data: [
{ id: 'Qualified', value: 35 },
{ id: 'Contacted', value: 25 },
{ id: 'Unqualified', value: 20 },
{ key: 'Qualified', value: 35 },
{ key: 'Contacted', value: 25 },
{ key: 'Unqualified', value: 20 },
],
showCenterMetric: true,
id: 'pie-chart-with-center-metric',
@@ -151,9 +151,9 @@ export const WithCenterMetric: Story = {
export const WithDataLabels: Story = {
args: {
data: [
{ id: 'Qualified', value: 35 },
{ id: 'Contacted', value: 25 },
{ id: 'Unqualified', value: 20 },
{ key: 'Qualified', value: 35 },
{ key: 'Contacted', value: 25 },
{ key: 'Unqualified', value: 20 },
],
showDataLabels: true,
id: 'pie-chart-with-data-labels',
@@ -174,10 +174,10 @@ export const WithDataLabels: Story = {
export const Revenue: Story = {
args: {
data: [
{ id: 'Product A', value: 420000 },
{ id: 'Product B', value: 380000 },
{ id: 'Product C', value: 250000 },
{ id: 'Product D', value: 180000 },
{ key: 'Product A', value: 420000 },
{ key: 'Product B', value: 380000 },
{ key: 'Product C', value: 250000 },
{ key: 'Product D', value: 180000 },
],
prefix: '$',
displayType: 'shortNumber',
@@ -205,9 +205,9 @@ export const Revenue: Story = {
export const TaskStatus: Story = {
args: {
data: [
{ id: 'Completed', value: 45 },
{ id: 'In Progress', value: 30 },
{ id: 'To Do', value: 25 },
{ key: 'Completed', value: 45 },
{ key: 'In Progress', value: 30 },
{ key: 'To Do', value: 25 },
],
displayType: 'percentage',
showLegend: true,
@@ -234,8 +234,8 @@ export const TaskStatus: Story = {
export const TwoSlices: Story = {
args: {
data: [
{ id: 'Active', value: 75 },
{ id: 'Inactive', value: 25 },
{ key: 'Active', value: 75 },
{ key: 'Inactive', value: 25 },
],
displayType: 'percentage',
showLegend: true,
@@ -262,14 +262,14 @@ export const TwoSlices: Story = {
export const ManySlices: Story = {
args: {
data: [
{ id: 'Category 1', value: 20 },
{ id: 'Category 2', value: 18 },
{ id: 'Category 3', value: 16 },
{ id: 'Category 4', value: 14 },
{ id: 'Category 5', value: 12 },
{ id: 'Category 6', value: 10 },
{ id: 'Category 7', value: 6 },
{ id: 'Category 8', value: 4 },
{ key: 'Category 1', value: 20 },
{ key: 'Category 2', value: 18 },
{ key: 'Category 3', value: 16 },
{ key: 'Category 4', value: 14 },
{ key: 'Category 5', value: 12 },
{ key: 'Category 6', value: 10 },
{ key: 'Category 7', value: 6 },
{ key: 'Category 8', value: 4 },
],
showLegend: true,
id: 'pie-chart-many-slices',
@@ -295,9 +295,9 @@ export const ManySlices: Story = {
export const WithoutLegend: Story = {
args: {
data: [
{ id: 'Web', value: 45 },
{ id: 'Mobile', value: 35 },
{ id: 'Desktop', value: 20 },
{ key: 'Web', value: 45 },
{ key: 'Mobile', value: 35 },
{ key: 'Desktop', value: 20 },
],
displayType: 'percentage',
showLegend: false,
@@ -324,10 +324,10 @@ export const WithoutLegend: Story = {
export const MarketShare: Story = {
args: {
data: [
{ id: 'Brand A', value: 35.5 },
{ id: 'Brand B', value: 28.2 },
{ id: 'Brand C', value: 18.7 },
{ id: 'Others', value: 17.6 },
{ key: 'Brand A', value: 35.5 },
{ key: 'Brand B', value: 28.2 },
{ key: 'Brand C', value: 18.7 },
{ key: 'Others', value: 17.6 },
],
displayType: 'percentage',
showLegend: true,
@@ -354,10 +354,10 @@ export const MarketShare: Story = {
export const Storage: Story = {
args: {
data: [
{ id: 'Documents', value: 125 },
{ id: 'Media', value: 280 },
{ id: 'Applications', value: 95 },
{ id: 'System', value: 50 },
{ key: 'Documents', value: 125 },
{ key: 'Media', value: 280 },
{ key: 'Applications', value: 95 },
{ key: 'System', value: 50 },
],
suffix: ' GB',
showLegend: true,
@@ -400,23 +400,23 @@ export const Catalog: Story = {
props: (sliceCount: number) => {
const dataMap: Record<
number,
Array<{ id: string; value: number }>
Array<{ key: string; value: number }>
> = {
2: [
{ id: 'Yes', value: 65 },
{ id: 'No', value: 35 },
{ key: 'Yes', value: 65 },
{ key: 'No', value: 35 },
],
3: [
{ id: 'Gold', value: 45 },
{ id: 'Silver', value: 35 },
{ id: 'Bronze', value: 20 },
{ key: 'Gold', value: 45 },
{ key: 'Silver', value: 35 },
{ key: 'Bronze', value: 20 },
],
5: [
{ id: 'Item 1', value: 30 },
{ id: 'Item 2', value: 25 },
{ id: 'Item 3', value: 20 },
{ id: 'Item 4', value: 15 },
{ id: 'Item 5', value: 10 },
{ key: 'Item 1', value: 30 },
{ key: 'Item 2', value: 25 },
{ key: 'Item 3', value: 20 },
{ key: 'Item 4', value: 15 },
{ key: 'Item 5', value: 10 },
],
};
@@ -37,7 +37,10 @@ export const CustomStackedAreasLayer = ({
const seriesById = useMemo(
() =>
new Map(
enrichedSeries.map((seriesItem) => [String(seriesItem.id), seriesItem]),
enrichedSeries.map((seriesItem) => [
String(seriesItem.key),
seriesItem,
]),
),
[enrichedSeries],
);
@@ -3,7 +3,6 @@ import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/Graph
import { renderHook } from '@testing-library/react';
import { useLineChartData } from '@/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartData';
import { type LineChartSeries } from '~/generated-metadata/graphql';
const mockUseAtomComponentStateValue = jest.fn();
jest.mock(
@@ -60,7 +59,7 @@ describe('useLineChartData', () => {
const mockData: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: 100 },
{ x: 'Feb', y: 120 },
@@ -70,7 +69,7 @@ describe('useLineChartData', () => {
color: 'red',
},
{
id: 'series2',
key: 'series2',
data: [
{ x: 'Jan', y: 80 },
{ x: 'Feb', y: 90 },
@@ -171,10 +170,10 @@ describe('useLineChartData', () => {
expect(result.current.legendItems).toEqual([]);
});
it('should use series id as label when label is not provided', () => {
const dataWithoutLabel: LineChartSeries[] = [
it('should use series key as label when label is not provided', () => {
const dataWithoutLabel: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [{ x: 'Jan', y: 100 }],
label: 'series1',
},
@@ -205,7 +204,7 @@ describe('useLineChartData', () => {
);
expect(result.current.visibleData).toHaveLength(1);
expect(result.current.visibleData[0].id).toBe('series1');
expect(result.current.visibleData[0].key).toBe('series1');
expect(result.current.enrichedSeries).toHaveLength(1);
expect(result.current.nivoData).toHaveLength(1);
});
@@ -15,6 +15,7 @@ import {
LineChartDataDocument,
type LineChartConfiguration,
type LineChartDataPoint,
type LineChartSeries,
} from '~/generated-metadata/graphql';
type UseGraphLineChartWidgetDataProps = {
@@ -83,12 +84,8 @@ export const useGraphLineChartWidgetData = ({
});
const series = queryData?.lineChartData?.series?.map(
(seriesItem: {
id: string;
label: string;
data: Array<LineChartDataPoint>;
}): LineChartSeriesWithColor => {
const rawValue = formattedToRawLookup.get(seriesItem.id);
(seriesItem: LineChartSeries): LineChartSeriesWithColor => {
const rawValue = formattedToRawLookup.get(seriesItem.key);
const itemColor = determineChartItemColor({
configurationColor,
@@ -97,7 +94,7 @@ export const useGraphLineChartWidgetData = ({
});
return {
id: seriesItem.id,
key: seriesItem.key,
label: seriesItem.label,
color: itemColor,
data: seriesItem.data.map(
@@ -37,10 +37,10 @@ export const useLineChartData = ({
totalGroups: shouldApplyGradient ? data.length : undefined,
});
const sanitizedSeriesId = series.id
const sanitizedSeriesKey = series.key
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '');
const areaFillId = `areaFill-${id}-${sanitizedSeriesId}-${index}`;
const areaFillId = `areaFill-${id}-${sanitizedSeriesKey}-${index}`;
return { ...series, colorScheme, areaFillId };
});
@@ -48,22 +48,22 @@ export const useLineChartData = ({
const legendItems: GraphWidgetLegendItem[] = allEnrichedSeries.map(
(series) => ({
id: series.id,
id: series.key,
label: series.label,
color: series.colorScheme.solid,
}),
);
const visibleData = data.filter(
(series) => !graphWidgetHiddenLegendIds.includes(series.id),
(series) => !graphWidgetHiddenLegendIds.includes(series.key),
);
const enrichedSeries = allEnrichedSeries.filter(
(series) => !graphWidgetHiddenLegendIds.includes(series.id),
(series) => !graphWidgetHiddenLegendIds.includes(series.key),
);
const nivoData: LineSeries[] = visibleData.map((series) => ({
id: series.id,
id: series.key,
data: series.data.map((point) => ({ x: point.x, y: point.y })),
}));
@@ -1,9 +1,8 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartDataPoint';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type LineChartSeries } from '~/generated-metadata/graphql';
export type LineChartSeriesWithColor = {
id: string;
label: string;
export type LineChartSeriesWithColor = Omit<LineChartSeries, 'data'> & {
color?: GraphColor;
data: LineChartDataPoint[];
};
@@ -1,13 +1,12 @@
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graph-widget-line-chart/utils/calculateValueRangeFromLineChartSeries';
import { type LineChartSeries } from '~/generated-metadata/graphql';
describe('calculateValueRangeFromLineChartSeries', () => {
describe('with valid data', () => {
it('should calculate minimum and maximum from single series', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 50 },
@@ -25,20 +24,20 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should calculate minimum and maximum from multiple series', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 },
],
},
{
id: 'series2',
key: 'series2',
data: [
{ x: 'Jan', y: 5 },
{ x: 'Feb', y: 100 },
],
},
] as unknown as LineChartSeries[];
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
@@ -49,7 +48,7 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should handle negative values', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: -50 },
{ x: 'Feb', y: 25 },
@@ -67,7 +66,7 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should handle all same values', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: 42 },
{ x: 'Feb', y: 42 },
@@ -87,7 +86,7 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should treat null y values as 0', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: null },
{ x: 'Feb', y: 50 },
@@ -104,7 +103,7 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should treat undefined y values as 0', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [
{ x: 'Jan', y: undefined },
{ x: 'Feb', y: 30 },
@@ -132,7 +131,7 @@ describe('calculateValueRangeFromLineChartSeries', () => {
it('should handle series with empty data array', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
data: [],
},
] as unknown as LineChartSeriesWithColor[];
@@ -17,7 +17,7 @@ describe('getLineChartLayout', () => {
const defaultData: LineChartSeriesWithColor[] = [
{
id: 'series1',
key: 'series1',
label: 'Series 1',
data: [
{ x: '2024-01', y: 10 },
@@ -90,7 +90,7 @@ describe('getLineChartLayout', () => {
...baseParams,
data: [
{
id: 'series1',
key: 'series1',
label: 'Series 1',
data: [{ x: '2024-01', y: 50 }],
},
@@ -119,7 +119,7 @@ describe('getLineChartLayout', () => {
...baseParams,
data: [
{
id: 'series1',
key: 'series1',
label: 'Series 1',
data: [
{ x: '2024-01', y: 10 },
@@ -127,7 +127,7 @@ describe('getLineChartLayout', () => {
],
},
{
id: 'series2',
key: 'series2',
label: 'Series 2',
data: [
{ x: '2024-01', y: 15 },
@@ -43,17 +43,17 @@ const createMockSlice = (
points,
}) as unknown as SliceTooltipProps<LineSeries>['slice'];
const createSeries = (id: string): LineChartEnrichedSeries =>
const createSeries = (key: string): LineChartEnrichedSeries =>
({
id,
label: id,
key,
label: key,
data: [],
colorScheme: {
name: 'test',
solid: '#solid',
variations: Array(12).fill('#v0'),
},
areaFillId: `area-${id}`,
areaFillId: `area-${key}`,
}) as unknown as LineChartEnrichedSeries;
describe('getLineChartTooltipData', () => {
@@ -26,7 +26,7 @@ export const getLineChartTooltipData = ({
isStacked = false,
}: GetLineChartTooltipDataParameters): LineChartTooltipData => {
const enrichedSeriesMap = new Map(
enrichedSeries.map((series) => [series.id, series]),
enrichedSeries.map((series) => [series.key, series]),
);
if (!isDefined(slice.points) || slice.points.length === 0) {
@@ -37,7 +37,7 @@ export const getLineChartTooltipData = ({
}
const seriesIndexMap = new Map(
enrichedSeries.map((series, index) => [series.id, index]),
enrichedSeries.map((series, index) => [series.key, index]),
);
const getPointValue = (point: (typeof slice.points)[number]) =>
@@ -65,7 +65,7 @@ export const getLineChartTooltipData = ({
const value = getPointValue(point);
return {
key: enrichedSeriesItem.id,
key: enrichedSeriesItem.key,
label: enrichedSeriesItem.label,
formattedValue: formatGraphValue(value, formatOptions),
value,
@@ -38,7 +38,7 @@ export const CustomArcsLayer = ({
{transition((_, datum) => {
const isDimmed =
isDefined(graphWidgetHighlightedLegendId) &&
String(graphWidgetHighlightedLegendId) !== String(datum.data.id);
String(graphWidgetHighlightedLegendId) !== String(datum.data.key);
const arcLength = datum.arc.endAngle - datum.arc.startAngle;
const padAngleRadians = (padAngle * Math.PI) / 180;
const clampedPadAngle = Math.min(
@@ -29,10 +29,7 @@ import {
} from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
type PieChartConfiguration,
type PieChartDataItem,
} from '~/generated-metadata/graphql';
import { type PieChartConfiguration } from '~/generated-metadata/graphql';
type GraphWidgetPieChartProps = {
data: PieChartDataItemWithColor[];
@@ -46,7 +43,9 @@ type GraphWidgetPieChartProps = {
showCenterMetric?: boolean;
} & GraphValueFormatOptions;
const emptyStateData: PieChartDataItemWithColor[] = [{ id: 'empty', value: 1 }];
const emptyStateData: PieChartDataItemWithColor[] = [
{ key: 'empty', value: 1 },
];
const StyledContainer = styled.div`
align-items: center;
@@ -194,8 +193,10 @@ export const GraphWidgetPieChart = ({
enableArcLabels={false}
tooltip={() => null}
layers={[ArcsLayer, 'arcLinkLabels']}
id={(datum: PieChartDataItem) => `${id}:${String(datum.id)}`}
arcLinkLabel={(datum: ComputedDatum<PieChartDataItem>) => {
id={(datum: PieChartDataItemWithColor) =>
`${id}:${String(datum.key)}`
}
arcLinkLabel={(datum: ComputedDatum<PieChartDataItemWithColor>) => {
const formattedValue = getPieChartFormattedValue({
datum,
enrichedData,
@@ -65,7 +65,7 @@ export const GraphWidgetPieChartRenderer = () => {
isFilteredViewRedirectionSupported(groupByField);
const handleSliceClick = (datum: PieChartDataItemWithColor) => {
const rawValue = formattedToRawLookup.get(datum.id) ?? null;
const rawValue = formattedToRawLookup.get(datum.key) ?? null;
const drilldownQueryParams = buildChartDrilldownQueryParams({
objectMetadataItem,
@@ -0,0 +1,104 @@
import { InMemoryCache } from '@apollo/client';
import { LINE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/lineChartData';
import { PIE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/pieChartData';
// Pie slices and line series are returned with `key` (the bucket label) rather than `id`,
// so Apollo's default id-based normalization never pools them into a shared cache entry.
// Without this, two widgets sharing a slice/series label overwrite each other's value
// (one chart "copies" the other). Mirrors the production cache config in useApolloFactory.ts.
const buildCache = () =>
new InMemoryCache({
typePolicies: {
RemoteTable: { keyFields: ['name'] },
},
});
const variablesForFilter = (filter: string) => ({
input: { objectMetadataId: 'opportunity', configuration: { filter } },
});
const writePie = (cache: InMemoryCache, filter: string, value: number) => {
cache.writeQuery({
query: PIE_CHART_DATA,
variables: variablesForFilter(filter),
data: {
pieChartData: {
__typename: 'PieChartData',
data: [{ __typename: 'PieChartDataItem', key: 'New', value }],
showLegend: true,
showDataLabels: false,
showCenterMetric: true,
hasTooManyGroups: false,
formattedToRawLookup: {},
},
},
});
};
const readPieNewValue = (cache: InMemoryCache, filter: string) =>
cache.readQuery<{ pieChartData: { data: Array<{ value: number }> } }>({
query: PIE_CHART_DATA,
variables: variablesForFilter(filter),
})?.pieChartData.data[0].value;
const writeLine = (cache: InMemoryCache, filter: string, y: number) => {
cache.writeQuery({
query: LINE_CHART_DATA,
variables: variablesForFilter(filter),
data: {
lineChartData: {
__typename: 'LineChartData',
series: [
{
__typename: 'LineChartSeries',
key: 'Won',
label: 'Won',
data: [{ __typename: 'LineChartDataPoint', x: '2024', y }],
},
],
xAxisLabel: '',
yAxisLabel: '',
showLegend: true,
showDataLabels: false,
hasTooManyGroups: false,
formattedToRawLookup: {},
},
},
});
};
const readLineFirstY = (cache: InMemoryCache, filter: string) =>
cache.readQuery<{
lineChartData: { series: Array<{ data: Array<{ y: number }> }> };
}>({
query: LINE_CHART_DATA,
variables: variablesForFilter(filter),
})?.lineChartData.series[0].data[0].y;
const hasNormalizedEntry = (cache: InMemoryCache, typename: string) =>
Object.keys(cache.extract()).some((cacheKey) =>
cacheKey.startsWith(`${typename}:`),
);
describe('chart data cache normalization', () => {
it('keeps pie slice values independent when two widgets share a slice label', () => {
const cache = buildCache();
writePie(cache, 'all', 100);
writePie(cache, 'lo', 5);
expect(readPieNewValue(cache, 'all')).toBe(100);
expect(hasNormalizedEntry(cache, 'PieChartDataItem')).toBe(false);
});
it('keeps line series values independent when two widgets share a series key', () => {
const cache = buildCache();
writeLine(cache, 'all', 100);
writeLine(cache, 'lo', 5);
expect(readLineFirstY(cache, 'all')).toBe(100);
expect(hasNormalizedEntry(cache, 'LineChartSeries')).toBe(false);
});
});
@@ -2,7 +2,6 @@ import { usePieChartData } from '@/page-layout/widgets/graph/graph-widget-pie-ch
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graph-widget-pie-chart/types/PieChartDataItem';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { type PieChartDataItem } from '~/generated-metadata/graphql';
const mockUseAtomComponentStateValue = jest.fn();
jest.mock(
@@ -58,9 +57,9 @@ describe('usePieChartData', () => {
};
const mockData: PieChartDataItemWithColor[] = [
{ id: 'item1', value: 30 },
{ id: 'item2', value: 50 },
{ id: 'item3', value: 20 },
{ key: 'item1', value: 30 },
{ key: 'item2', value: 50 },
{ key: 'item3', value: 20 },
];
it('should enrich data with color schemes and percentages', () => {
@@ -74,7 +73,7 @@ describe('usePieChartData', () => {
expect(result.current.enrichedData).toHaveLength(3);
expect(result.current.enrichedData[0]).toMatchObject({
id: 'item1',
key: 'item1',
value: 30,
percentage: 30,
colorScheme: mockColorRegistry.red,
@@ -96,7 +95,9 @@ describe('usePieChartData', () => {
});
it('should handle single data item', () => {
const singleData: PieChartDataItem[] = [{ id: 'single', value: 100 }];
const singleData: PieChartDataItemWithColor[] = [
{ key: 'single', value: 100 },
];
const { result } = renderHook(() =>
usePieChartData({
@@ -151,7 +152,7 @@ describe('usePieChartData', () => {
);
expect(result.current.enrichedData).toHaveLength(2);
expect(result.current.enrichedData.map((d) => d.id)).toEqual([
expect(result.current.enrichedData.map((item) => item.key)).toEqual([
'item1',
'item3',
]);
@@ -15,6 +15,7 @@ import { isDefined } from 'twenty-shared/utils';
import {
PieChartDataDocument,
type PieChartConfiguration,
type PieChartDataItem,
} from '~/generated-metadata/graphql';
type UseGraphPieChartWidgetDataProps = {
@@ -85,8 +86,8 @@ export const useGraphPieChartWidgetData = ({
});
const chartData = queryData?.pieChartData?.data?.map(
(item: PieChartDataItemWithColor): PieChartDataItemWithColor => {
const rawValue = formattedToRawLookup.get(item.id);
(item: PieChartDataItem): PieChartDataItemWithColor => {
const rawValue = formattedToRawLookup.get(item.key);
const itemColor = determineChartItemColor({
configurationColor,
@@ -95,7 +96,7 @@ export const useGraphPieChartWidgetData = ({
});
return {
id: item.id,
key: item.key,
value: item.value,
color: itemColor,
};
@@ -48,17 +48,17 @@ export const usePieChartData = ({
}, [data, colorRegistry, colorMode]);
const legendItems: GraphWidgetLegendItem[] = allEnrichedData.map((item) => ({
id: item.id,
label: String(item.id),
id: item.key,
label: String(item.key),
color: item.colorScheme.solid,
}));
const enrichedData = allEnrichedData.filter(
(item) => !graphWidgetHiddenLegendIds.includes(item.id),
(item) => !graphWidgetHiddenLegendIds.includes(item.key),
);
const enrichedDataMap = useMemo(
() => new Map(enrichedData.map((item) => [item.id, item])),
() => new Map(enrichedData.map((item) => [item.key, item])),
[enrichedData],
);
@@ -26,19 +26,19 @@ describe('getPieChartFormattedValue', () => {
const mockEnrichedData: PieChartEnrichedData[] = [
{
id: 'slice1',
key: 'slice1',
value: 30,
percentage: 30,
colorScheme: mockColorScheme,
},
{
id: 'slice2',
key: 'slice2',
value: 50,
percentage: 50,
colorScheme: mockColorScheme,
},
{
id: 'slice3',
key: 'slice3',
value: 20,
percentage: 20,
colorScheme: mockColorScheme,
@@ -46,13 +46,13 @@ describe('getPieChartFormattedValue', () => {
];
const createMockDatum = (
id: string,
key: string,
options?: { computedId?: string },
): ComputedDatum<PieChartDataItemWithColor> =>
({
id: options?.computedId ?? id,
id: options?.computedId ?? key,
value: 0,
data: { id, value: 0 },
data: { key, value: 0 },
}) as unknown as ComputedDatum<PieChartDataItemWithColor>;
const defaultFormatOptions = {
@@ -84,7 +84,7 @@ describe('getPieChartFormattedValue', () => {
expect(result).toBeNull();
});
it('should match by datum.data.id when computed id is namespaced per widget', () => {
it('should match by datum.data.key when computed id is namespaced per widget', () => {
const datum = createMockDatum('slice1', {
computedId: 'widget-abc:slice1',
});
@@ -145,7 +145,7 @@ describe('getPieChartFormattedValue', () => {
it('should format percentage to one decimal place', () => {
const enrichedDataWithDecimal: PieChartEnrichedData[] = [
{
id: 'slice1',
key: 'slice1',
value: 33,
percentage: 33.333,
colorScheme: mockColorScheme,
@@ -168,7 +168,7 @@ describe('getPieChartFormattedValue', () => {
it('should handle zero value', () => {
const enrichedDataWithZero: PieChartEnrichedData[] = [
{
id: 'zero',
key: 'zero',
value: 0,
percentage: 0,
colorScheme: mockColorScheme,
@@ -189,7 +189,7 @@ describe('getPieChartFormattedValue', () => {
it('should handle 100% value', () => {
const enrichedDataWith100: PieChartEnrichedData[] = [
{
id: 'full',
key: 'full',
value: 100,
percentage: 100,
colorScheme: mockColorScheme,
@@ -26,19 +26,19 @@ describe('getPieChartTooltipData', () => {
const mockEnrichedData: PieChartEnrichedData[] = [
{
id: 'Product A',
key: 'Product A',
value: 500,
percentage: 50,
colorScheme: mockColorScheme,
},
{
id: 'Product B',
key: 'Product B',
value: 300,
percentage: 30,
colorScheme: { ...mockColorScheme, solid: '#solidB' },
},
{
id: 'Product C',
key: 'Product C',
value: 200,
percentage: 20,
colorScheme: { ...mockColorScheme, solid: '#solidC' },
@@ -46,13 +46,13 @@ describe('getPieChartTooltipData', () => {
];
const createMockDatum = (
id: string,
key: string,
options?: { computedId?: string },
): ComputedDatum<PieChartDataItemWithColor> =>
({
id: options?.computedId ?? id,
id: options?.computedId ?? key,
value: 0,
data: { id, value: 0 },
data: { key, value: 0 },
}) as unknown as ComputedDatum<PieChartDataItemWithColor>;
const defaultFormatOptions = {
@@ -150,7 +150,7 @@ describe('getPieChartTooltipData', () => {
expect(result).toBeNull();
});
it('should match by datum.data.id when computed id is namespaced per widget', () => {
it('should match by datum.data.key when computed id is namespaced per widget', () => {
const datum = createMockDatum('Product A', {
computedId: 'widget-xyz:Product A',
});
@@ -182,7 +182,7 @@ describe('getPieChartTooltipData', () => {
it('should handle item with zero value', () => {
const enrichedDataWithZero: PieChartEnrichedData[] = [
{
id: 'Zero Item',
key: 'Zero Item',
value: 0,
percentage: 0,
colorScheme: mockColorScheme,
@@ -203,7 +203,7 @@ describe('getPieChartTooltipData', () => {
it('should handle special characters in id', () => {
const enrichedDataWithSpecialChars: PieChartEnrichedData[] = [
{
id: 'Item & Special <chars>',
key: 'Item & Special <chars>',
value: 100,
percentage: 100,
colorScheme: mockColorScheme,
@@ -22,7 +22,7 @@ export const getPieChartFormattedValue = ({
displayType,
}: GetPieChartFormattedValueParams): string | null => {
const item = enrichedData.find(
(enrichedDataItem) => enrichedDataItem.id === datum.data.id,
(enrichedDataItem) => enrichedDataItem.key === datum.data.key,
);
if (!isDefined(item)) return null;
@@ -23,7 +23,7 @@ export const getPieChartTooltipData = ({
tooltipItem: GraphWidgetTooltipItem;
} | null => {
const item = enrichedData.find(
(enrichedDataItem) => enrichedDataItem.id === datum.data.id,
(enrichedDataItem) => enrichedDataItem.key === datum.data.key,
);
if (!isDefined(item)) return null;
@@ -37,8 +37,8 @@ export const getPieChartTooltipData = ({
return {
tooltipItem: {
key: item.id,
label: item.id,
key: item.key,
label: item.key,
formattedValue,
value: item.value,
dotColor: item.colorScheme.solid,
@@ -4,7 +4,7 @@ export const LINE_CHART_DATA = gql`
query LineChartData($input: LineChartDataInput!) {
lineChartData(input: $input) {
series {
id
key
label
data {
x
@@ -4,7 +4,7 @@ export const PIE_CHART_DATA = gql`
query PieChartData($input: PieChartDataInput!) {
pieChartData(input: $input) {
data {
id
key
value
}
showLegend
@@ -63,7 +63,7 @@ export const UsageDailyChartSection = ({
const lineData: LineChartSeriesWithColor[] = [
{
id: chartId,
key: chartId,
label: chartLabel,
data: timeSeries.map((point) => ({
x: formatDate(point.date, 'MMM d'),
@@ -5,7 +5,7 @@ import { LineChartDataPointDTO } from 'src/modules/dashboard/chart-data/dtos/lin
@ObjectType('LineChartSeries')
export class LineChartSeriesDTO {
@Field(() => String)
id: string;
key: string;
@Field(() => String)
label: string;
@@ -3,7 +3,7 @@ import { Field, Float, ObjectType } from '@nestjs/graphql';
@ObjectType('PieChartDataItem')
export class PieChartDataItemDTO {
@Field(() => String)
id: string;
key: string;
@Field(() => Float)
value: number;
@@ -137,11 +137,11 @@ describe('PieChartDataService', () => {
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({
id: 'Active',
key: 'Active',
value: 10,
});
expect(result.data[1]).toEqual({
id: 'Inactive',
key: 'Inactive',
value: 5,
});
expect(result.showLegend).toBe(true);
@@ -162,8 +162,8 @@ describe('PieChartDataService', () => {
});
expect(result.data).toEqual([
{ id: 'Not Set', value: 2 },
{ id: 'Active', value: 5 },
{ key: 'Not Set', value: 2 },
{ key: 'Active', value: 5 },
]);
expect(result.formattedToRawLookup?.['Not Set']).toBeUndefined();
expect(result.formattedToRawLookup?.['Active']).toBe('Active');
@@ -186,7 +186,7 @@ describe('PieChartDataService', () => {
});
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe('Active');
expect(result.data[0].key).toBe('Active');
});
it('should flag too many groups and limit slices', async () => {
@@ -313,8 +313,8 @@ describe('PieChartDataService', () => {
authContext: mockAuthContext,
});
expect(result.data[0].id).toBe('Open');
expect(result.data[1].id).toBe('Closed');
expect(result.data[0].key).toBe('Open');
expect(result.data[1].key).toBe('Closed');
});
});
@@ -461,8 +461,8 @@ describe('PieChartDataService', () => {
authContext: mockAuthContext,
});
expect(result.data[0].id).toBe('Yes');
expect(result.data[1].id).toBe('No');
expect(result.data[0].key).toBe('Yes');
expect(result.data[1].key).toBe('No');
});
});
});
@@ -295,7 +295,7 @@ export class LineChartDataService {
const series = [
{
id: `${seriesIdPrefix}${aggregateField.name}`,
key: `${seriesIdPrefix}${aggregateField.name}`,
label: aggregateField.label,
data: dataPoints,
},
@@ -480,7 +480,7 @@ export class LineChartDataService {
}
return {
id: prefixedSeriesId,
key: prefixedSeriesId,
label: seriesId,
data: dataPoints,
};
@@ -203,7 +203,7 @@ export class PieChartDataService {
: null;
return {
id: point.formattedValue,
key: point.formattedValue,
value: point.aggregateValue,
rawValue: rawValueString,
};
@@ -214,7 +214,7 @@ export class PieChartDataService {
orderBy: configuration.orderBy,
manualSortOrder: configuration.manualSortOrder,
formattedToRawLookup,
getFieldValue: (item) => item.id,
getFieldValue: (item) => item.key,
getNumericValue: (item) => item.value,
selectFieldOptions: selectOptions,
fieldType: groupByField.type,