[DASHBOARDS] Add cumulative setting for bar chart and line chart (#16248)

## Description

- Add cumulative setting
- This setting is only present for dates
- It is preserved when switching from a date field to another date field
but it is reset when switching to another field type

## Video QA


https://github.com/user-attachments/assets/a070bf54-8ef6-4e35-9378-a514fe1c3848


https://github.com/user-attachments/assets/07edfe87-253c-45a5-b582-06f2df9a2dfc
This commit is contained in:
Raphaël Bosi
2025-12-02 14:00:58 +01:00
committed by GitHub
parent 00e970bdf4
commit 1038efa3dd
34 changed files with 766 additions and 19 deletions
@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`applyCumulativeTransformToBarChartData should handle basic accumulation 1`] = `
[
{
"id": "a",
"value": 10,
},
{
"id": "b",
"value": 30,
},
{
"id": "c",
"value": 60,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToBarChartData should handle filter above rangeMax 1`] = `
[
{
"id": "a",
"value": 10,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle filter below rangeMin 1`] = `
[
{
"id": "b",
"value": 20,
},
{
"id": "c",
"value": 30,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle skip non-numeric values 1`] = `
[
{
"id": "a",
"value": 10,
},
{
"id": "b",
"value": 10,
},
{
"id": "c",
"value": 30,
},
]
`;
@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`applyCumulativeTransformToLineChartData should handle basic accumulation 1`] = `
[
{
"x": "Jan",
"y": 10,
},
{
"x": "Feb",
"y": 30,
},
{
"x": "Mar",
"y": 60,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToLineChartData should handle filter above rangeMax 1`] = `
[
{
"x": "a",
"y": 10,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle filter below rangeMin 1`] = `
[
{
"x": "b",
"y": 20,
},
{
"x": "c",
"y": 30,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle skip null y values 1`] = `
[
{
"x": "a",
"y": 10,
},
{
"x": "b",
"y": 10,
},
{
"x": "c",
"y": 30,
},
]
`;
@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle accumulate each key independently 1`] = `
[
{
"id": "Jan",
"revenue": 100,
"sales": 10,
},
{
"id": "Feb",
"revenue": 300,
"sales": 30,
},
{
"id": "Mar",
"revenue": 600,
"sales": 60,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter above rangeMax based on total sum 1`] = `
[
{
"id": "a",
"x": 10,
"y": 10,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter below rangeMin based on total sum 1`] = `
[
{
"id": "c",
"x": 30,
"y": 30,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle skip non-numeric values 1`] = `
[
{
"id": "a",
"x": 10,
},
{
"id": "b",
"x": 10,
},
{
"id": "c",
"x": 30,
},
]
`;
@@ -0,0 +1,63 @@
import { applyCumulativeTransformToBarChartData } from '../applyCumulativeTransformToBarChartData';
describe('applyCumulativeTransformToBarChartData', () => {
const testCases = [
{
name: 'basic accumulation',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 20 },
{ id: 'c', value: 30 },
],
aggregateKey: 'value',
},
{
name: 'filter below rangeMin',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 10 },
{ id: 'c', value: 10 },
],
aggregateKey: 'value',
rangeMin: 15,
},
{
name: 'filter above rangeMax',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 20 },
{ id: 'c', value: 30 },
],
aggregateKey: 'value',
rangeMax: 25,
},
{
name: 'empty data',
data: [],
aggregateKey: 'value',
},
{
name: 'skip non-numeric values',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 'not a number' },
{ id: 'c', value: 20 },
],
aggregateKey: 'value',
},
];
it.each(testCases)(
'should handle $name',
({ data, aggregateKey, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToBarChartData({
data,
aggregateKey,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
},
);
});
@@ -0,0 +1,54 @@
import { applyCumulativeTransformToLineChartData } from '../applyCumulativeTransformToLineChartData';
describe('applyCumulativeTransformToLineChartData', () => {
const testCases = [
{
name: 'basic accumulation',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 },
{ x: 'Mar', y: 30 },
],
},
{
name: 'filter below rangeMin',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: 10 },
{ x: 'c', y: 10 },
],
rangeMin: 15,
},
{
name: 'filter above rangeMax',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: 20 },
{ x: 'c', y: 30 },
],
rangeMax: 25,
},
{
name: 'empty data',
data: [],
},
{
name: 'skip null y values',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: null },
{ x: 'c', y: 20 },
],
},
];
it.each(testCases)('should handle $name', ({ data, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToLineChartData({
data,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
});
});
@@ -0,0 +1,63 @@
import { applyCumulativeTransformToTwoDimensionalBarChartData } from '../applyCumulativeTransformToTwoDimensionalBarChartData';
describe('applyCumulativeTransformToTwoDimensionalBarChartData', () => {
const testCases = [
{
name: 'accumulate each key independently',
data: [
{ id: 'Jan', sales: 10, revenue: 100 },
{ id: 'Feb', sales: 20, revenue: 200 },
{ id: 'Mar', sales: 30, revenue: 300 },
],
keys: ['sales', 'revenue'],
},
{
name: 'filter below rangeMin based on total sum',
data: [
{ id: 'a', x: 10, y: 10 },
{ id: 'b', x: 10, y: 10 },
{ id: 'c', x: 10, y: 10 },
],
keys: ['x', 'y'],
rangeMin: 50,
},
{
name: 'filter above rangeMax based on total sum',
data: [
{ id: 'a', x: 10, y: 10 },
{ id: 'b', x: 20, y: 20 },
{ id: 'c', x: 30, y: 30 },
],
keys: ['x', 'y'],
rangeMax: 50,
},
{
name: 'empty data',
data: [],
keys: ['x', 'y'],
},
{
name: 'skip non-numeric values',
data: [
{ id: 'a', x: 10 },
{ id: 'b', x: 'not a number' },
{ id: 'c', x: 20 },
],
keys: ['x'],
},
];
it.each(testCases)(
'should handle $name',
({ data, keys, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToTwoDimensionalBarChartData({
data,
keys,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
},
);
});
@@ -0,0 +1,41 @@
import { type BarDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToBarChartDataOptions = {
data: BarDatum[];
aggregateKey: string;
rangeMin?: number;
rangeMax?: number;
};
export const applyCumulativeTransformToBarChartData = ({
data,
aggregateKey,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToBarChartDataOptions): BarDatum[] => {
const { result } = data.reduce<{ result: BarDatum[]; runningTotal: number }>(
(accumulator, datum) => {
const value = datum[aggregateKey];
if (typeof value === 'number') {
accumulator.runningTotal += value;
}
const cumulativeValue = accumulator.runningTotal;
const isOutOfRange =
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push({ ...datum, [aggregateKey]: cumulativeValue });
}
return accumulator;
},
{ result: [], runningTotal: 0 },
);
return result;
};
@@ -0,0 +1,40 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToLineChartDataOptions = {
data: LineChartDataPoint[];
rangeMin?: number;
rangeMax?: number;
};
export const applyCumulativeTransformToLineChartData = ({
data,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToLineChartDataOptions): LineChartDataPoint[] => {
const { result } = data.reduce<{
result: LineChartDataPoint[];
runningTotal: number;
}>(
(accumulator, point) => {
if (point.y !== null) {
accumulator.runningTotal += point.y;
}
const cumulativeValue = accumulator.runningTotal;
const isOutOfRange =
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push({ ...point, y: cumulativeValue });
}
return accumulator;
},
{ result: [], runningTotal: 0 },
);
return result;
};
@@ -0,0 +1,56 @@
import { type BarDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions = {
data: BarDatum[];
keys: string[];
rangeMin?: number;
rangeMax?: number;
};
export const applyCumulativeTransformToTwoDimensionalBarChartData = ({
data,
keys,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions): BarDatum[] => {
const { result } = data.reduce<{
result: BarDatum[];
runningTotals: Record<string, number>;
}>(
(accumulator, datum) => {
const newDatum = { ...datum };
for (const key of keys) {
const value = datum[key];
if (typeof value === 'number') {
accumulator.runningTotals[key] += value;
}
newDatum[key] = accumulator.runningTotals[key];
}
const totalValue = keys.reduce((sum, key) => {
const value = newDatum[key];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
const isOutOfRange =
(isDefined(rangeMin) && totalValue < rangeMin) ||
(isDefined(rangeMax) && totalValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push(newDatum);
}
return accumulator;
},
{
result: [],
runningTotals: Object.fromEntries(keys.map((key) => [key, 0])),
},
);
return result;
};
@@ -89,8 +89,12 @@ export const transformGroupByDataToLineChartData = ({
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
rangeMin: configuration.isCumulative
? undefined
: (configuration.rangeMin ?? undefined),
rangeMax: configuration.isCumulative
? undefined
: (configuration.rangeMax ?? undefined),
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
@@ -8,10 +8,11 @@ import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLin
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
@@ -91,12 +92,20 @@ export const transformOneDimensionalGroupByToLineChartData = ({
})
.filter((point) => isDefined(point));
const transformedData = configuration.isCumulative
? applyCumulativeTransformToLineChartData({
data,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
: data;
const series: LineChartSeries[] = [
{
id: aggregateField.name,
label: aggregateField.label,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
data,
data: transformedData,
},
];
@@ -7,10 +7,11 @@ import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLin
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
@@ -117,11 +118,19 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
y: xToYMap.get(xValue) ?? 0,
}));
const transformedData = configuration.isCumulative
? applyCumulativeTransformToLineChartData({
data,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
: data;
return {
id: seriesKey,
label: seriesKey,
color: configuration.color as GraphColor,
data,
data: transformedData,
};
},
);