From d359496b8b595ffceb21afd30c55ceb81bca019c Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:33:50 +0530
Subject: [PATCH] Keep chart palette colors stable when series order changes
(#23638)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
https://discord.com/channels/1130383047699738754/1522812783140540538
Chart palette colors were assigned by array position, so changing the
sort order (or any reordering of the data) reshuffled every series color
on line, bar and pie charts. Grouping by a select field was unaffected
since options carry their own colors — this only hit groupings without
intrinsic colors (relations, text fields, etc).
Colors are now assigned by the alphabetical rank of the series key, so a
key keeps its color no matter what order the data arrives in. Side
effect: existing palette-colored charts get a one-time color
reassignment.
---
.../hooks/__tests__/useBarChartData.test.ts | 80 +++++++++++++
.../hooks/useBarChartData.ts | 10 +-
.../hooks/__tests__/useLineChartData.test.ts | 90 +++++++++++++++
.../hooks/useLineChartData.ts | 10 +-
.../hooks/__tests__/usePieChartData.test.ts | 109 +++++++++++++++++-
.../hooks/usePieChartData.ts | 12 +-
.../buildAlphabeticalRankByKey.test.ts | 38 ++++++
.../utils/__tests__/getColorScheme.test.ts | 96 +++++++++++----
.../graph/utils/buildAlphabeticalRankByKey.ts | 7 ++
.../widgets/graph/utils/getColorScheme.ts | 28 +++--
10 files changed, 435 insertions(+), 45 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildAlphabeticalRankByKey.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey.ts
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/__tests__/useBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/__tests__/useBarChartData.test.ts
index d9656ae73c..5315384d6d 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/__tests__/useBarChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/__tests__/useBarChartData.test.ts
@@ -1,4 +1,5 @@
import { useBarChartData } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartData';
+import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartEnrichedKey';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
@@ -259,4 +260,83 @@ describe('useBarChartData', () => {
expect(result.current.visibleKeys).toEqual(['sales', 'costs']);
expect(result.current.enrichedKeys).toHaveLength(2);
});
+
+ const colorsByKey = (enrichedKeys: BarChartEnrichedKey[]) =>
+ Object.fromEntries(
+ enrichedKeys.map((item) => [item.key, item.colorScheme.name]),
+ );
+
+ const shadesByKey = (enrichedKeys: BarChartEnrichedKey[]) =>
+ Object.fromEntries(
+ enrichedKeys.map((item) => [item.key, item.colorScheme.solid]),
+ );
+
+ it('should keep the same automatic palette color per key when key order changes', () => {
+ const { result: firstOrderResult } = renderHook(() =>
+ useBarChartData({
+ keys: ['sales', 'revenue', 'expenses'],
+ series: undefined,
+ colorRegistry: mockColorRegistry,
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ useBarChartData({
+ keys: ['expenses', 'sales', 'revenue'],
+ series: undefined,
+ colorRegistry: mockColorRegistry,
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const expectedColorsByKey = {
+ sales: 'green',
+ revenue: 'purple',
+ expenses: 'green',
+ };
+
+ expect(colorsByKey(firstOrderResult.current.enrichedKeys)).toEqual(
+ expectedColorsByKey,
+ );
+ expect(colorsByKey(reorderedResult.current.enrichedKeys)).toEqual(
+ expectedColorsByKey,
+ );
+ });
+
+ it('should keep the same gradient shade per key when key order changes in explicitSingleColor mode', () => {
+ const gradientSeries = (keys: string[]): BarChartSeriesWithColor[] =>
+ keys.map((key) => ({ key, label: key, color: 'green' }));
+
+ const { result: firstOrderResult } = renderHook(() =>
+ useBarChartData({
+ keys: ['sales', 'costs', 'revenue'],
+ series: gradientSeries(['sales', 'costs', 'revenue']),
+ colorRegistry: mockColorRegistry,
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ useBarChartData({
+ keys: ['revenue', 'sales', 'costs'],
+ series: gradientSeries(['revenue', 'sales', 'costs']),
+ colorRegistry: mockColorRegistry,
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const expectedShadesByKey = {
+ costs: 'green4',
+ revenue: 'green6',
+ sales: 'green8',
+ };
+
+ expect(shadesByKey(firstOrderResult.current.enrichedKeys)).toEqual(
+ expectedShadesByKey,
+ );
+ expect(shadesByKey(reorderedResult.current.enrichedKeys)).toEqual(
+ expectedShadesByKey,
+ );
+ });
});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartData.ts
index 2dc30798d2..3545177f7e 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartData.ts
@@ -4,6 +4,7 @@ import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
+import { buildAlphabeticalRankByKey } from '@/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey';
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useMemo } from 'react';
@@ -37,15 +38,16 @@ export const useBarChartData = ({
);
const allEnrichedKeys = useMemo((): BarChartEnrichedKey[] => {
- const shouldApplyGradient = colorMode === 'explicitSingleColor';
+ const alphabeticalRankByKey = buildAlphabeticalRankByKey(keys);
- return keys.map((key, index) => {
+ return keys.map((key) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: seriesConfig?.color,
- fallbackIndex: index,
- totalGroups: shouldApplyGradient ? keys.length : undefined,
+ colorKey: key,
+ colorMode,
+ alphabeticalRankByKey,
});
return {
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/__tests__/useLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/__tests__/useLineChartData.test.ts
index a5dadd2edb..f5d9bc68f7 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/__tests__/useLineChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/__tests__/useLineChartData.test.ts
@@ -1,3 +1,4 @@
+import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartEnrichedSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
@@ -257,6 +258,95 @@ describe('useLineChartData', () => {
expect(result.current.nivoData[0].id).toBe('series2');
});
+ it('should keep the same automatic palette color per key when series order changes', () => {
+ const series = (key: string): LineChartSeriesWithColor => ({
+ key,
+ label: key,
+ data: [{ x: 'Jan', y: 100 }],
+ });
+
+ const { result: firstOrderResult } = renderHook(() =>
+ useLineChartData({
+ data: [series('gamma'), series('alpha'), series('beta')],
+ colorRegistry: mockColorRegistry,
+ id: 'test-chart',
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ useLineChartData({
+ data: [series('beta'), series('gamma'), series('alpha')],
+ colorRegistry: mockColorRegistry,
+ id: 'test-chart',
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const colorsByKey = (enrichedSeries: LineChartEnrichedSeries[]) =>
+ Object.fromEntries(
+ enrichedSeries.map((item) => [item.key, item.colorScheme.name]),
+ );
+
+ const expectedColorsByKey = {
+ alpha: 'red',
+ beta: 'blue',
+ gamma: 'red',
+ };
+
+ expect(colorsByKey(firstOrderResult.current.enrichedSeries)).toEqual(
+ expectedColorsByKey,
+ );
+ expect(colorsByKey(reorderedResult.current.enrichedSeries)).toEqual(
+ expectedColorsByKey,
+ );
+ });
+
+ it('should keep the same gradient shade per key when series order changes in explicitSingleColor mode', () => {
+ const series = (key: string): LineChartSeriesWithColor => ({
+ key,
+ label: key,
+ data: [{ x: 'Jan', y: 100 }],
+ color: 'red',
+ });
+
+ const { result: firstOrderResult } = renderHook(() =>
+ useLineChartData({
+ data: [series('won'), series('open'), series('lost')],
+ colorRegistry: mockColorRegistry,
+ id: 'test-chart',
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ useLineChartData({
+ data: [series('lost'), series('won'), series('open')],
+ colorRegistry: mockColorRegistry,
+ id: 'test-chart',
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const shadesByKey = (enrichedSeries: LineChartEnrichedSeries[]) =>
+ Object.fromEntries(
+ enrichedSeries.map((item) => [item.key, item.colorScheme.solid]),
+ );
+
+ const expectedShadesByKey = {
+ lost: 'red4',
+ open: 'red6',
+ won: 'red8',
+ };
+
+ expect(shadesByKey(firstOrderResult.current.enrichedSeries)).toEqual(
+ expectedShadesByKey,
+ );
+ expect(shadesByKey(reorderedResult.current.enrichedSeries)).toEqual(
+ expectedShadesByKey,
+ );
+ });
+
it('should handle hidden ids that do not exist in data', () => {
mockUseAtomComponentStateValue.mockReturnValue([
'nonexistent',
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartData.ts
index 6a41ea7dd7..667580fb09 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartData.ts
@@ -4,6 +4,7 @@ import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
+import { buildAlphabeticalRankByKey } from '@/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey';
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type LineSeries } from '@nivo/line';
@@ -27,14 +28,17 @@ export const useLineChartData = ({
);
const allEnrichedSeries = useMemo((): LineChartEnrichedSeries[] => {
- const shouldApplyGradient = colorMode === 'explicitSingleColor';
+ const alphabeticalRankByKey = buildAlphabeticalRankByKey(
+ data.map((series) => series.key),
+ );
return data.map((series, index) => {
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: series.color,
- fallbackIndex: index,
- totalGroups: shouldApplyGradient ? data.length : undefined,
+ colorKey: series.key,
+ colorMode,
+ alphabeticalRankByKey,
});
const sanitizedSeriesKey = series.key
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/__tests__/usePieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/__tests__/usePieChartData.test.ts
index 7064fd0716..55752ae6de 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/__tests__/usePieChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/__tests__/usePieChartData.test.ts
@@ -1,5 +1,6 @@
import { usePieChartData } from '@/page-layout/widgets/graph/graph-widget-pie-chart/hooks/usePieChartData';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graph-widget-pie-chart/types/PieChartDataItem';
+import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graph-widget-pie-chart/types/PieChartEnrichedData';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
@@ -62,6 +63,16 @@ describe('usePieChartData', () => {
{ key: 'item3', value: 20 },
];
+ const colorsByKey = (enrichedData: PieChartEnrichedData[]) =>
+ Object.fromEntries(
+ enrichedData.map((item) => [item.key, item.colorScheme.name]),
+ );
+
+ const shadesByKey = (enrichedData: PieChartEnrichedData[]) =>
+ Object.fromEntries(
+ enrichedData.map((item) => [item.key, item.colorScheme.solid]),
+ );
+
it('should enrich data with color schemes and percentages', () => {
const { result } = renderHook(() =>
usePieChartData({
@@ -110,17 +121,24 @@ describe('usePieChartData', () => {
expect(result.current.enrichedData[0].percentage).toBe(100);
});
- it('should assign colors based on index', () => {
+ it('should assign automatic palette colors by alphabetical rank', () => {
const { result } = renderHook(() =>
usePieChartData({
- data: mockData,
+ data: [
+ { key: 'beta', value: 20 },
+ { key: 'gamma', value: 30 },
+ { key: 'delta', value: 50 },
+ ],
colorRegistry: mockColorRegistry,
colorMode: 'automaticPalette',
}),
);
- expect(result.current.enrichedData[0].colorScheme.name).toBe('red');
- expect(result.current.enrichedData[1].colorScheme.name).toBe('blue');
+ expect(colorsByKey(result.current.enrichedData)).toEqual({
+ gamma: 'red',
+ delta: 'blue',
+ beta: 'red',
+ });
});
it('should return legend items from all data', () => {
@@ -219,4 +237,87 @@ describe('usePieChartData', () => {
expect(result.current.enrichedData).toHaveLength(3);
});
+
+ it('should keep the same automatic palette color per key when item order changes', () => {
+ const { result: firstOrderResult } = renderHook(() =>
+ usePieChartData({
+ data: [
+ { key: 'gamma', value: 30 },
+ { key: 'alpha', value: 50 },
+ { key: 'beta', value: 20 },
+ ],
+ colorRegistry: mockColorRegistry,
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ usePieChartData({
+ data: [
+ { key: 'beta', value: 20 },
+ { key: 'gamma', value: 30 },
+ { key: 'alpha', value: 50 },
+ ],
+ colorRegistry: mockColorRegistry,
+ colorMode: 'automaticPalette',
+ }),
+ );
+
+ const expectedColorsByKey = {
+ alpha: 'red',
+ beta: 'blue',
+ gamma: 'red',
+ };
+
+ expect(colorsByKey(firstOrderResult.current.enrichedData)).toEqual(
+ expectedColorsByKey,
+ );
+ expect(colorsByKey(reorderedResult.current.enrichedData)).toEqual(
+ expectedColorsByKey,
+ );
+ });
+
+ it('should keep the same gradient shade per key when item order changes in explicitSingleColor mode', () => {
+ const gradientItem = (
+ key: string,
+ value: number,
+ ): PieChartDataItemWithColor => ({ key, value, color: 'blue' });
+
+ const { result: firstOrderResult } = renderHook(() =>
+ usePieChartData({
+ data: [
+ gradientItem('won', 30),
+ gradientItem('open', 50),
+ gradientItem('lost', 20),
+ ],
+ colorRegistry: mockColorRegistry,
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const { result: reorderedResult } = renderHook(() =>
+ usePieChartData({
+ data: [
+ gradientItem('lost', 20),
+ gradientItem('won', 30),
+ gradientItem('open', 50),
+ ],
+ colorRegistry: mockColorRegistry,
+ colorMode: 'explicitSingleColor',
+ }),
+ );
+
+ const expectedShadesByKey = {
+ lost: 'blue4',
+ open: 'blue6',
+ won: 'blue8',
+ };
+
+ expect(shadesByKey(firstOrderResult.current.enrichedData)).toEqual(
+ expectedShadesByKey,
+ );
+ expect(shadesByKey(reorderedResult.current.enrichedData)).toEqual(
+ expectedShadesByKey,
+ );
+ });
});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/usePieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/usePieChartData.ts
index 6b96d2afd5..9fa3a8c0d4 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/usePieChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-pie-chart/hooks/usePieChartData.ts
@@ -5,6 +5,7 @@ import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graph-w
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
+import { buildAlphabeticalRankByKey } from '@/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey';
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useMemo } from 'react';
@@ -27,14 +28,17 @@ export const usePieChartData = ({
const allEnrichedData = useMemo((): PieChartEnrichedData[] => {
const totalValue = data.reduce((sum, item) => sum + item.value, 0);
- const shouldApplyGradient = colorMode === 'explicitSingleColor';
+ const alphabeticalRankByKey = buildAlphabeticalRankByKey(
+ data.map((item) => item.key),
+ );
- return data.map((item, index) => {
+ return data.map((item) => {
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: item.color,
- fallbackIndex: index,
- totalGroups: shouldApplyGradient ? data.length : undefined,
+ colorKey: item.key,
+ colorMode,
+ alphabeticalRankByKey,
});
const percentage = calculatePieChartPercentage(item.value, totalValue);
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildAlphabeticalRankByKey.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildAlphabeticalRankByKey.test.ts
new file mode 100644
index 0000000000..4d0c9a1061
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildAlphabeticalRankByKey.test.ts
@@ -0,0 +1,38 @@
+import { buildAlphabeticalRankByKey } from '@/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey';
+
+describe('buildAlphabeticalRankByKey', () => {
+ it('should assign ranks by alphabetical order of keys', () => {
+ const result = buildAlphabeticalRankByKey(['charlie', 'alpha', 'bravo']);
+
+ expect(result.get('alpha')).toBe(0);
+ expect(result.get('bravo')).toBe(1);
+ expect(result.get('charlie')).toBe(2);
+ });
+
+ it('should return the same ranks regardless of input order', () => {
+ const firstOrder = buildAlphabeticalRankByKey(['open', 'won', 'lost']);
+ const secondOrder = buildAlphabeticalRankByKey(['lost', 'open', 'won']);
+
+ expect(firstOrder).toEqual(secondOrder);
+ });
+
+ it('should deduplicate keys so ranks stay dense', () => {
+ const result = buildAlphabeticalRankByKey(['bravo', 'alpha', 'bravo']);
+
+ expect(result.size).toBe(2);
+ expect(result.get('alpha')).toBe(0);
+ expect(result.get('bravo')).toBe(1);
+ });
+
+ it('should handle an empty key list', () => {
+ expect(buildAlphabeticalRankByKey([]).size).toBe(0);
+ });
+
+ it('should not mutate the input array', () => {
+ const keys = ['bravo', 'alpha'];
+
+ buildAlphabeticalRankByKey(keys);
+
+ expect(keys).toEqual(['bravo', 'alpha']);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts
index ccce05a16a..962207a383 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts
@@ -59,11 +59,20 @@ describe('getColorScheme', () => {
},
} as unknown as GraphColorRegistry;
+ const alphabeticalRankByKey = new Map([
+ ['alpha', 0],
+ ['beta', 1],
+ ['gamma', 2],
+ ]);
+
describe('with valid color name', () => {
it('should return the color scheme for a valid color name', () => {
const result = getColorScheme({
registry: mockRegistry,
colorName: 'blue',
+ colorKey: 'alpha',
+ colorMode: 'selectFieldOptionColors',
+ alphabeticalRankByKey,
});
expect(result).toEqual(mockRegistry.blue);
@@ -73,6 +82,9 @@ describe('getColorScheme', () => {
const result = getColorScheme({
registry: mockRegistry,
colorName: 'BLUE' as 'blue',
+ colorKey: 'alpha',
+ colorMode: 'selectFieldOptionColors',
+ alphabeticalRankByKey,
});
expect(result).toEqual(mockRegistry.blue);
@@ -82,6 +94,9 @@ describe('getColorScheme', () => {
const result = getColorScheme({
registry: mockRegistry,
colorName: 'Blue' as 'blue',
+ colorKey: 'alpha',
+ colorMode: 'selectFieldOptionColors',
+ alphabeticalRankByKey,
});
expect(result).toEqual(mockRegistry.blue);
@@ -89,62 +104,101 @@ describe('getColorScheme', () => {
});
describe('with invalid or missing color name', () => {
- it('should return color scheme by fallback index when color name is undefined', () => {
- const result = getColorScheme({
+ it('should return the color scheme at the alphabetical rank', () => {
+ const firstResult = getColorScheme({
registry: mockRegistry,
- colorName: undefined,
- fallbackIndex: 0,
+ colorKey: 'alpha',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
+ });
+ const secondResult = getColorScheme({
+ registry: mockRegistry,
+ colorKey: 'beta',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
+ });
+ const thirdResult = getColorScheme({
+ registry: mockRegistry,
+ colorKey: 'gamma',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
});
- expect(result.name).toBeDefined();
+ expect(firstResult.name).toBe('blue');
+ expect(secondResult.name).toBe('green');
+ expect(thirdResult.name).toBe('red');
});
- it('should return color scheme by fallback index when color name is not in registry', () => {
+ it('should use the alphabetical rank when color name is not in registry', () => {
const result = getColorScheme({
registry: mockRegistry,
colorName: 'invalidColor' as 'blue',
- fallbackIndex: 1,
+ colorKey: 'beta',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
});
- expect(result.name).toBeDefined();
+ expect(result.name).toBe('green');
});
- it('should use fallback index 0 when not provided', () => {
- const result = getColorScheme({
+ it('should return the same color for the same key on every call', () => {
+ const first = getColorScheme({
registry: mockRegistry,
- colorName: undefined,
+ colorKey: 'beta',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
+ });
+ const second = getColorScheme({
+ registry: mockRegistry,
+ colorKey: 'beta',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
});
- expect(result.name).toBeDefined();
+ expect(first).toEqual(second);
+ });
+
+ it('should throw when the color key has no alphabetical rank', () => {
+ expect(() =>
+ getColorScheme({
+ registry: mockRegistry,
+ colorKey: 'missing',
+ colorMode: 'automaticPalette',
+ alphabeticalRankByKey,
+ }),
+ ).toThrow('Missing alphabetical rank for color key "missing"');
});
});
- describe('with totalGroups parameter', () => {
- it('should generate a group color when totalGroups is provided', () => {
+ describe('with explicit single color mode', () => {
+ it('should generate a group color', () => {
const result = getColorScheme({
registry: mockRegistry,
colorName: 'blue',
- fallbackIndex: 0,
- totalGroups: 5,
+ colorKey: 'alpha',
+ colorMode: 'explicitSingleColor',
+ alphabeticalRankByKey,
});
expect(result.name).toBe('blue');
expect(result.variations).toEqual(mockRegistry.blue.variations);
});
- it('should use fallbackIndex for group color generation', () => {
+ it('should use alphabetical rank for group color generation', () => {
const result1 = getColorScheme({
registry: mockRegistry,
colorName: 'green',
- fallbackIndex: 0,
- totalGroups: 3,
+ colorKey: 'alpha',
+ colorMode: 'explicitSingleColor',
+ alphabeticalRankByKey,
});
const result2 = getColorScheme({
registry: mockRegistry,
colorName: 'green',
- fallbackIndex: 2,
- totalGroups: 3,
+ colorKey: 'gamma',
+ colorMode: 'explicitSingleColor',
+ alphabeticalRankByKey,
});
expect(result1.solid).not.toBe(result2.solid);
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey.ts
new file mode 100644
index 0000000000..de6bb9324b
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildAlphabeticalRankByKey.ts
@@ -0,0 +1,7 @@
+export const buildAlphabeticalRankByKey = (
+ keys: string[],
+): Map => {
+ const uniqueSortedKeys = [...new Set(keys)].sort();
+
+ return new Map(uniqueSortedKeys.map((key, rank) => [key, rank]));
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts
index 0d5a722ca5..c7240b5bd2 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts
@@ -1,21 +1,31 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
+import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor';
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
-import { isDefined } from 'twenty-shared/utils';
+import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
export const getColorScheme = ({
registry,
colorName,
- fallbackIndex,
- totalGroups,
+ colorKey,
+ colorMode,
+ alphabeticalRankByKey,
}: {
registry: GraphColorRegistry;
colorName?: GraphColor;
- fallbackIndex?: number;
- totalGroups?: number;
+ colorKey: string;
+ colorMode: GraphColorMode;
+ alphabeticalRankByKey: ReadonlyMap;
}): GraphColorScheme => {
+ const alphabeticalRank = alphabeticalRankByKey.get(colorKey);
+
+ assertIsDefinedOrThrow(
+ alphabeticalRank,
+ new Error(`Missing alphabetical rank for color key "${colorKey}"`),
+ );
+
const normalizedColorName = isDefined(colorName)
? (colorName.toLowerCase() as GraphColor)
: undefined;
@@ -24,10 +34,10 @@ export const getColorScheme = ({
!isDefined(normalizedColorName) ||
!isDefined(registry[normalizedColorName])
) {
- return getColorSchemeByIndex(registry, fallbackIndex ?? 0);
+ return getColorSchemeByIndex(registry, alphabeticalRank);
}
- if (!isDefined(totalGroups)) {
+ if (colorMode !== 'explicitSingleColor') {
return registry[normalizedColorName];
}
@@ -35,8 +45,8 @@ export const getColorScheme = ({
...registry[normalizedColorName],
solid: generateGroupColor({
colorScheme: registry[normalizedColorName],
- groupIndex: fallbackIndex ?? 0,
- totalGroups,
+ groupIndex: alphabeticalRank,
+ totalGroups: alphabeticalRankByKey.size,
}),
};
};