[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:
+9
-6
@@ -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(),
|
||||
|
||||
+6
-6
@@ -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();
|
||||
|
||||
+23
-2
@@ -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,
|
||||
|
||||
+17
@@ -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}:`;
|
||||
};
|
||||
Reference in New Issue
Block a user