[Dashboards] fix line chart duplicate widget bleed by hashing series IDs (#17303)

closes last issue in the bug bucket -
https://github.com/twentyhq/core-team-issues/issues/2109

<img width="695" height="41" alt="CleanShot 2026-01-21 at 19 40 38"
src="https://github.com/user-attachments/assets/c6872e11-1e36-4b4e-ab06-9943c22cfab2"
/>


 ### 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
This commit is contained in:
nitin
2026-01-21 21:09:29 +05:30
committed by GitHub
parent 5ee6853d7e
commit 26a70c23e9
4 changed files with 55 additions and 14 deletions
@@ -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(),
@@ -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();
@@ -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,
@@ -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}:`;
};