From 26a70c23e9275f7bd4c10c6d440842953ea8456f Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:09:29 +0530 Subject: [PATCH] [Dashboards] fix line chart duplicate widget bleed by hashing series IDs (#17303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes last issue in the bug bucket - https://github.com/twentyhq/core-team-issues/issues/2109 CleanShot 2026-01-21 at 19 40 38 ### Root cause - line chart series have stable IDs, so Apollo normalizes them across duplicated widgets and X‑axis edits leak ### Fix - prefix series IDs with a deterministic hash of objectMetadataId + configuration before - https://github.com/user-attachments/assets/538eded8-7036-415a-b707-a10007c3aa11 after - https://github.com/user-attachments/assets/cf9ca89a-e7c9-4106-b6cd-fbc072d9a233 --- .../usePageLayoutWithRelationWidgets.test.tsx | 15 ++++++----- .../__tests__/line-chart-data.service.spec.ts | 12 ++++----- .../services/line-chart-data.service.ts | 25 +++++++++++++++++-- .../build-line-chart-series-id-prefix.util.ts | 17 +++++++++++++ 4 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 packages/twenty-server/src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util.ts diff --git a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/usePageLayoutWithRelationWidgets.test.tsx b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/usePageLayoutWithRelationWidgets.test.tsx index 6792ed9e2f..f76ad20f76 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/__tests__/usePageLayoutWithRelationWidgets.test.tsx +++ b/packages/twenty-front/src/modules/page-layout/hooks/__tests__/usePageLayoutWithRelationWidgets.test.tsx @@ -5,6 +5,9 @@ import { type PageLayout } from '@/page-layout/types/PageLayout'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { renderHook } from '@testing-library/react'; import { + AggregateOperations, + BarChartLayout, + GraphOrderBy, PageLayoutType, WidgetConfigurationType, WidgetType, @@ -100,11 +103,11 @@ describe('usePageLayoutWithRelationWidgets', () => { configuration: { __typename: 'BarChartConfiguration', configurationType: WidgetConfigurationType.BAR_CHART, - layout: 'VERTICAL', - aggregateOperation: 'COUNT', + layout: BarChartLayout.VERTICAL, + aggregateOperation: AggregateOperations.COUNT, aggregateFieldMetadataId: 'id', primaryAxisGroupByFieldMetadataId: 'createdAt', - primaryAxisOrderBy: 'FIELD_ASC', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, displayDataLabel: false, }, createdAt: new Date().toISOString(), @@ -222,11 +225,11 @@ describe('usePageLayoutWithRelationWidgets', () => { configuration: { __typename: 'BarChartConfiguration', configurationType: WidgetConfigurationType.BAR_CHART, - layout: 'VERTICAL', - aggregateOperation: 'COUNT', + layout: BarChartLayout.VERTICAL, + aggregateOperation: AggregateOperations.COUNT, aggregateFieldMetadataId: 'id', primaryAxisGroupByFieldMetadataId: 'createdAt', - primaryAxisOrderBy: 'FIELD_ASC', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, displayDataLabel: false, }, createdAt: new Date().toISOString(), diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts index e0d29cf70d..840a67b64a 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts @@ -316,14 +316,14 @@ describe('LineChartDataService', () => { authContext: mockAuthContext, }); - const stageA = result.series.find((s) => s.id === 'Stage A'); + const stageA = result.series.find((s) => s.label === 'Stage A'); expect(stageA?.data).toHaveLength(3); expect(stageA?.data[0].y).toBe(100); expect(stageA?.data[1].y).toBe(200); expect(stageA?.data[2].y).toBe(0); - const stageB = result.series.find((s) => s.id === 'Stage B'); + const stageB = result.series.find((s) => s.label === 'Stage B'); expect(stageB?.data).toHaveLength(3); expect(stageB?.data[0].y).toBe(150); @@ -349,8 +349,8 @@ describe('LineChartDataService', () => { authContext: mockAuthContext, }); - const seriesA = result.series.find((s) => s.id === 'A'); - const seriesB = result.series.find((s) => s.id === 'B'); + const seriesA = result.series.find((s) => s.label === 'A'); + const seriesB = result.series.find((s) => s.label === 'B'); expect(seriesA?.data[0].y).toBe(10); expect(seriesA?.data[1].y).toBe(30); @@ -380,8 +380,8 @@ describe('LineChartDataService', () => { authContext: mockAuthContext, }); - const seriesA = result.series.find((s) => s.id === 'A'); - const seriesB = result.series.find((s) => s.id === 'B'); + const seriesA = result.series.find((s) => s.label === 'A'); + const seriesB = result.series.find((s) => s.label === 'B'); expect(seriesA).toBeDefined(); expect(seriesB).toBeDefined(); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts index e66e33f0ba..f89c39d800 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts @@ -36,6 +36,7 @@ import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/u import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util'; import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util'; import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util'; +import { buildLineChartSeriesIdPrefix } from 'src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util'; type GetLineChartDataParams = { workspaceId: string; @@ -164,6 +165,11 @@ export class LineChartDataService { secondaryAxisOrderBy: configuration.secondaryAxisOrderBy, }); + const seriesIdPrefix = buildLineChartSeriesIdPrefix( + objectMetadataId, + configuration, + ); + if (isTwoDimensional && isDefined(secondaryAxisGroupByField)) { return this.transformToTwoDimensionalLineChartData({ rawResults, @@ -173,6 +179,7 @@ export class LineChartDataService { configuration, userTimezone, firstDayOfTheWeek, + seriesIdPrefix, }); } @@ -183,6 +190,7 @@ export class LineChartDataService { configuration, userTimezone, firstDayOfTheWeek, + seriesIdPrefix, }); } catch (error) { if (error instanceof ChartDataException) { @@ -206,6 +214,7 @@ export class LineChartDataService { configuration, userTimezone, firstDayOfTheWeek, + seriesIdPrefix, }: { rawResults: GroupByRawResult[]; primaryAxisGroupByField: FlatFieldMetadata; @@ -213,6 +222,7 @@ export class LineChartDataService { configuration: LineChartConfigurationDTO; userTimezone: string; firstDayOfTheWeek: CalendarStartDay; + seriesIdPrefix: string; }): LineChartDataOutputDTO { const filteredResults = configuration.omitNullValues ? rawResults.filter( @@ -299,7 +309,7 @@ export class LineChartDataService { const series = [ { - id: aggregateField.name, + id: `${seriesIdPrefix}${aggregateField.name}`, label: aggregateField.label, data: dataPoints, }, @@ -329,6 +339,7 @@ export class LineChartDataService { configuration, userTimezone, firstDayOfTheWeek, + seriesIdPrefix, }: { rawResults: GroupByRawResult[]; primaryAxisGroupByField: FlatFieldMetadata; @@ -337,6 +348,7 @@ export class LineChartDataService { configuration: LineChartConfigurationDTO; userTimezone: string; firstDayOfTheWeek: CalendarStartDay; + seriesIdPrefix: string; }): LineChartDataOutputDTO { const filteredResults = configuration.omitNullValues ? rawResults.filter( @@ -491,6 +503,7 @@ export class LineChartDataService { const series = limitedSeriesIds.map((seriesId) => { const xToYMap = seriesMap.get(seriesId) ?? new Map(); + const prefixedSeriesId = `${seriesIdPrefix}${seriesId}`; let dataPoints = filteredXValues.map((xValue) => ({ x: xValue, @@ -502,7 +515,7 @@ export class LineChartDataService { } return { - id: seriesId, + id: prefixedSeriesId, label: seriesId, data: dataPoints, }; @@ -522,6 +535,14 @@ export class LineChartDataService { ...secondaryFormattedToRawLookup, ]); + for (const seriesId of limitedSeriesIds) { + const rawValue = secondaryFormattedToRawLookup.get(seriesId); + + if (isDefined(rawValue)) { + mergedLookup.set(`${seriesIdPrefix}${seriesId}`, rawValue); + } + } + return { series, xAxisLabel, diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util.ts new file mode 100644 index 0000000000..b632736690 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util.ts @@ -0,0 +1,17 @@ +import { createHash } from 'crypto'; + +import type { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto'; + +export const buildLineChartSeriesIdPrefix = ( + objectMetadataId: string, + configuration: LineChartConfigurationDTO, +): string => { + const hash = createHash('sha256') + .update(objectMetadataId) + .update(':') + .update(JSON.stringify(configuration)) + .digest('hex') + .slice(0, 16); + + return `lc_${hash}:`; +};