[Fix]: Vertical bar charts with max data range do not show data up to the top if above max data range (#18748)

fixes issue : #18606 

we have removed the irrelevant use of filters in the backend which
removes the values that are out of max, min range hence modifying the
rawValues.

- Modified relevant tests
- fixed regressions (Horizontal bar charts rightLabel cropping)
- removed dead utility filterByRange code

Before :
<img width="1253" height="722" alt="Screenshot 2026-03-18 at 10 24
14 PM"
src="https://github.com/user-attachments/assets/20adb5bb-0f96-4952-9777-1f1a6ebe03eb"
/>

After:
<img width="865" height="724" alt="Screenshot 2026-03-18 at 11 16 33 PM"
src="https://github.com/user-attachments/assets/b2695d85-ca5e-4923-8f57-8d3c87c8212a"
/>

<img width="1260" height="689" alt="Screenshot 2026-03-19 at 12 16
41 AM"
src="https://github.com/user-attachments/assets/fb25e15f-eec2-445e-9103-280d58664454"
/>

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
Arun
2026-03-21 01:03:21 +05:30
committed by GitHub
parent 2a1d22d870
commit d389a4341d
28 changed files with 414 additions and 757 deletions
@@ -104,7 +104,9 @@ export const getAxisLayerLayout = ({
-margins.left + axisConfig.leftAxisLegendOffsetPadding;
const valueRange = valueDomain.max - valueDomain.min;
const shouldRenderZeroLine = hasNegativeValues && valueRange !== 0;
const zeroIsWithinDomain =
valueDomain.min < 0 && valueDomain.max > 0 && valueRange !== 0;
const shouldRenderZeroLine = hasNegativeValues && zeroIsWithinDomain;
const zeroPosition = shouldRenderZeroLine
? isVertical
? innerHeight - ((0 - valueDomain.min) / valueRange) * innerHeight
@@ -36,6 +36,10 @@ export const renderGridLayer = ({
for (const tickValue of valueTickValues) {
const normalizedPosition = (tickValue - valueDomain.min) / range;
if (normalizedPosition <= 0 || normalizedPosition >= 1) {
continue;
}
ctx.beginPath();
if (isVertical) {
const y = innerHeight * (1 - normalizedPosition);
@@ -1,4 +1,5 @@
export const TEXT_MARGIN_EXTRAS = {
tickPaddingExtra: 4,
bottomTickExtraNonRotated: 20,
rightTickExtra: 16,
} as const;
@@ -1,4 +1,4 @@
export const TEXT_MARGIN_LIMITS = {
min: { top: 10, right: 10, bottom: 30, left: 40 },
max: { top: 30, right: 30, bottom: 140, left: 160 },
max: { top: 30, right: 160, bottom: 140, left: 160 },
} as const;
@@ -1,7 +1,7 @@
import { BarChartLayers } from '@/page-layout/widgets/graph/graph-widget-bar-chart/components/BarChartLayers';
import { useBarChartLayout } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartLayout';
import { useBarChartTheme } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartTheme';
import { useMemoizedBarPositions } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useMemoizedBarPositions';
import { useBarChartLayout } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useBarChartLayout';
import { type BarChartDatum } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartDatum';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartEnrichedKey';
import { type BarChartSlice } from '@/page-layout/widgets/graph/graph-widget-bar-chart/types/BarChartSlice';
@@ -30,12 +30,14 @@ type BarChartProps = {
layout: BarChartLayout;
groupMode: 'grouped' | 'stacked';
effectiveValueRange: { minimum: number; maximum: number };
hasExplicitRangeBounds: boolean;
formatOptions: GraphValueFormatOptions;
axisConfig?: {
xAxisLabel?: string;
yAxisLabel?: string;
showGrid?: boolean;
};
rightTickLabels?: string[];
dataLabelsConfig?: {
show: boolean;
omitNullValues: boolean;
@@ -65,8 +67,10 @@ export const BarChart = ({
layout,
groupMode,
effectiveValueRange,
hasExplicitRangeBounds,
formatOptions,
axisConfig,
rightTickLabels,
dataLabelsConfig,
hoveredSliceIndexValue,
onSliceHover,
@@ -102,11 +106,13 @@ export const BarChart = ({
chartWidth,
data,
effectiveValueRange,
hasExplicitRangeBounds,
formatOptions,
groupMode,
indexBy,
keys,
layout,
rightTickLabels,
});
const isVerticalLayout = layout === BarChartLayout.VERTICAL;
@@ -1,4 +1,3 @@
import { isSidePanelAnimatingState } from '@/side-panel/states/isSidePanelAnimatingState';
import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { pageLayoutResizingWidgetIdComponentState } from '@/page-layout/states/pageLayoutResizingWidgetIdComponentState';
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
@@ -17,12 +16,17 @@ import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { computeEffectiveValueRange } from '@/page-layout/widgets/graph/utils/computeEffectiveValueRange';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { isSidePanelAnimatingState } from '@/side-panel/states/isSidePanelAnimatingState';
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { styled } from '@linaria/react';
import { isNumber } from '@sniptt/guards';
import { useContext, useMemo, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
@@ -120,13 +124,16 @@ export const GraphWidgetBarChart = ({
const allowDataTransitions = !isLayoutAnimating;
const formatOptions: GraphValueFormatOptions = {
customFormatter,
decimals,
displayType,
prefix,
suffix,
};
const formatOptions = useMemo<GraphValueFormatOptions>(
() => ({
customFormatter,
decimals,
displayType,
prefix,
suffix,
}),
[customFormatter, decimals, displayType, prefix, suffix],
);
const { enrichedKeysMap, enrichedKeys, legendItems, visibleKeys } =
useBarChartData({ keys, series, colorRegistry, seriesLabels, colorMode });
@@ -151,6 +158,36 @@ export const GraphWidgetBarChart = ({
rangeMin,
});
const hasExplicitRangeBounds = isDefined(rangeMin) || isDefined(rangeMax);
const rightTickLabels = useMemo(() => {
if (
!hasExplicitRangeBounds ||
!showValues ||
layout !== BarChartLayout.HORIZONTAL
) {
return undefined;
}
const labels: string[] = [];
for (const dataItem of data) {
for (const visibleKey of visibleKeys) {
const value = dataItem[visibleKey];
if (isNumber(value)) {
labels.push(formatGraphValue(value, formatOptions));
}
}
}
return labels;
}, [
data,
visibleKeys,
formatOptions,
hasExplicitRangeBounds,
showValues,
layout,
]);
const dataByIndexValue = useMemo(
() => new Map(data.map((row) => [String(row[indexBy]), row])),
[data, indexBy],
@@ -222,8 +259,10 @@ export const GraphWidgetBarChart = ({
maximum: effectiveMaximumValue,
minimum: effectiveMinimumValue,
}}
hasExplicitRangeBounds={hasExplicitRangeBounds}
enrichedKeysMap={enrichedKeysMap}
formatOptions={formatOptions}
rightTickLabels={rightTickLabels}
groupMode={groupMode}
hasNoData={hasNoData}
hoveredSliceIndexValue={graphWidgetHoveredSliceIndex}
@@ -21,11 +21,13 @@ type UseBarChartLayoutParams = {
chartWidth: number;
data: BarChartDatum[];
effectiveValueRange: { minimum: number; maximum: number };
hasExplicitRangeBounds: boolean;
formatOptions: GraphValueFormatOptions;
groupMode: 'grouped' | 'stacked';
indexBy: string;
keys: string[];
layout: BarChartLayout;
rightTickLabels?: string[];
};
type UseBarChartLayoutResult = {
@@ -52,11 +54,13 @@ export const useBarChartLayout = ({
chartWidth,
data,
effectiveValueRange,
hasExplicitRangeBounds,
formatOptions,
groupMode,
indexBy,
keys,
layout,
rightTickLabels,
}: UseBarChartLayoutParams): UseBarChartLayoutResult => {
const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme);
@@ -73,9 +77,11 @@ export const useBarChartLayout = ({
data,
effectiveMaximumValue: effectiveValueRange.maximum,
effectiveMinimumValue: effectiveValueRange.minimum,
hasExplicitRangeBounds,
formatOptions,
indexBy,
layout,
rightTickLabels,
xAxisLabel: axisConfig?.xAxisLabel,
yAxisLabel: axisConfig?.yAxisLabel,
});
@@ -142,4 +142,28 @@ describe('computeBarPositions', () => {
expect(bar2).toBeDefined();
expect(bar1!.y).toBeGreaterThan(bar2!.y);
});
it('clamps horizontal bars to value axis when value is above explicit max', () => {
const chartWidth = 300;
const result = computeBarPositions({
data: [{ category: 'A', value1: 15 }],
indexBy: 'category',
keys: ['value1'],
enrichedKeysMap: defaultEnrichedKeysMap,
chartWidth,
chartHeight: 300,
margins: defaultMargins,
layout: BarChartLayout.HORIZONTAL,
groupMode: 'grouped',
valueDomain: { min: 0, max: 10 },
innerPadding: 2,
});
expect(result).toHaveLength(1);
expect(result[0].x).toBe(0);
expect(result[0].width).toBe(
chartWidth - defaultMargins.left - defaultMargins.right,
);
});
});
@@ -124,6 +124,23 @@ describe('getBarChartLayout', () => {
TEXT_MARGIN_LIMITS.max.left,
);
});
it('keeps explicit range bounds as value domain', () => {
const result = getBarChartLayout({
...baseParams,
chartWidth: 280,
layout: BarChartLayout.HORIZONTAL,
effectiveMinimumValue: 0,
effectiveMaximumValue: 10,
hasExplicitRangeBounds: true,
});
expect(result.valueDomain).toEqual({ min: 0, max: 10 });
expect(result.valueTickValues[0]).toBe(0);
expect(result.valueTickValues[result.valueTickValues.length - 1]).toBe(
10,
);
});
});
describe('edge cases', () => {
@@ -59,4 +59,44 @@ describe('getGroupedBarDimensions', () => {
height: 19,
});
});
it('clamps vertical bar when value exceeds axis length', () => {
const ctx = createContext({ isVertical: true });
const layout = computeGroupedBarLayout(ctx, 2);
const dimensions = getGroupedBarDimensions({
ctx,
layout,
categoryStart: 10,
keyIndex: 1,
value: 150,
});
expect(dimensions).toEqual({
x: 31,
y: 0,
width: 19,
height: 100,
});
});
it('clamps horizontal bar when value exceeds axis length', () => {
const ctx = createContext({ isVertical: false });
const layout = computeGroupedBarLayout(ctx, 2);
const dimensions = getGroupedBarDimensions({
ctx,
layout,
categoryStart: 10,
keyIndex: 0,
value: 150,
});
expect(dimensions).toEqual({
x: 0,
y: 10,
width: 100,
height: 19,
});
});
});
@@ -114,4 +114,92 @@ describe('getStackedBarDimensions', () => {
newNegativeStackPixel: 30,
});
});
it('clamps vertical positive bar when stack exceeds axis length', () => {
const ctx = createContext({ isVertical: true });
const dimensions = getStackedBarDimensions({
ctx,
layout,
categoryStart: 20,
value: 40,
positiveStackPixel: 80,
negativeStackPixel: 50,
});
expect(dimensions).toEqual({
x: 22,
y: 0,
width: 10,
height: 20,
newPositiveStackPixel: 120,
newNegativeStackPixel: 50,
});
});
it('clamps vertical negative bar when stack goes below zero', () => {
const ctx = createContext({ isVertical: true });
const dimensions = getStackedBarDimensions({
ctx,
layout,
categoryStart: 20,
value: -40,
positiveStackPixel: 50,
negativeStackPixel: 20,
});
expect(dimensions).toEqual({
x: 22,
y: 80,
width: 10,
height: 20,
newPositiveStackPixel: 50,
newNegativeStackPixel: -20,
});
});
it('clamps horizontal positive bar when stack exceeds axis length', () => {
const ctx = createContext({ isVertical: false });
const dimensions = getStackedBarDimensions({
ctx,
layout,
categoryStart: 20,
value: 40,
positiveStackPixel: 80,
negativeStackPixel: 50,
});
expect(dimensions).toEqual({
x: 80,
y: 22,
width: 20,
height: 10,
newPositiveStackPixel: 120,
newNegativeStackPixel: 50,
});
});
it('clamps horizontal negative bar when stack goes below zero', () => {
const ctx = createContext({ isVertical: false });
const dimensions = getStackedBarDimensions({
ctx,
layout,
categoryStart: 20,
value: -40,
positiveStackPixel: 50,
negativeStackPixel: 20,
});
expect(dimensions).toEqual({
x: 0,
y: 22,
width: 20,
height: 10,
newPositiveStackPixel: 50,
newNegativeStackPixel: -20,
});
});
});
@@ -31,6 +31,8 @@ type GetBarChartLayoutParams = {
formatOptions: GraphValueFormatOptions;
effectiveMinimumValue: number;
effectiveMaximumValue: number;
hasExplicitRangeBounds?: boolean;
rightTickLabels?: string[];
};
type BarChartLayoutResult = {
@@ -127,6 +129,8 @@ export const getBarChartLayout = ({
formatOptions,
effectiveMinimumValue,
effectiveMaximumValue,
hasExplicitRangeBounds = false,
rightTickLabels = [],
}: GetBarChartLayoutParams): BarChartLayoutResult => {
const { tickFontSize, legendFontSize } = resolveAxisFontSizes(axisTheme);
@@ -152,6 +156,7 @@ export const getBarChartLayout = ({
minimum: effectiveMinimumValue,
maximum: effectiveMaximumValue,
tickCount: currentTickConfiguration.numberOfValueTicks,
preserveDomainBounds: hasExplicitRangeBounds,
}),
getTickRotation: (currentTickConfiguration) =>
currentTickConfiguration.bottomAxisTickRotation,
@@ -161,13 +166,19 @@ export const getBarChartLayout = ({
tickFontSize,
tickRotation: parameters.tickConfiguration.bottomAxisTickRotation,
}),
resolveMarginInputs: (currentTickConfiguration, tickResult) =>
resolveMarginInputs({
resolveMarginInputs: (currentTickConfiguration, tickResult) => {
const marginInputs = resolveMarginInputs({
tickConfiguration: currentTickConfiguration,
tickResult,
layout,
formatOptions,
}),
});
return {
...marginInputs,
rightTickLabels,
};
},
});
const { tickValues: valueTickValues, domain: valueDomain } = valueTickResult;
@@ -52,9 +52,13 @@ export const getGroupedBarDimensions = ({
const { isVertical, valueAxisLength, valueToPixel, zeroPixel } = ctx;
const { barThickness, groupCenteringOffset, barStride } = layout;
const valuePixel = valueToPixel(value);
const barStart = Math.min(zeroPixel, valuePixel);
const barLength = Math.abs(valuePixel - zeroPixel);
const valuePixel = Math.max(
0,
Math.min(valueAxisLength, valueToPixel(value)),
);
const clampedZeroPixel = Math.max(0, Math.min(valueAxisLength, zeroPixel));
const barStart = Math.min(clampedZeroPixel, valuePixel);
const barLength = Math.abs(valuePixel - clampedZeroPixel);
const categoryPosition =
categoryStart + groupCenteringOffset + keyIndex * barStride;
@@ -74,45 +74,64 @@ export const getStackedBarDimensions = ({
const valuePixelDelta =
stackRange === 0 ? 0 : stackValueToPixel(Math.abs(value));
const clampToAxis = (pixel: number) =>
Math.max(0, Math.min(valueAxisLength, pixel));
if (isVertical && isNegative) {
const newStack = negativeStackPixel - valuePixelDelta;
const clampedCurrent = clampToAxis(negativeStackPixel);
const clampedNew = clampToAxis(newStack);
return {
x: categoryPosition,
y: valueAxisLength - negativeStackPixel,
y: valueAxisLength - clampedCurrent,
width: barThickness,
height: valuePixelDelta,
height: clampedCurrent - clampedNew,
newPositiveStackPixel: positiveStackPixel,
newNegativeStackPixel: negativeStackPixel - valuePixelDelta,
newNegativeStackPixel: newStack,
};
}
if (isVertical) {
const newStack = positiveStackPixel + valuePixelDelta;
const clampedCurrent = clampToAxis(positiveStackPixel);
const clampedNew = clampToAxis(newStack);
return {
x: categoryPosition,
y: valueAxisLength - (positiveStackPixel + valuePixelDelta),
y: valueAxisLength - clampedNew,
width: barThickness,
height: valuePixelDelta,
newPositiveStackPixel: positiveStackPixel + valuePixelDelta,
height: clampedNew - clampedCurrent,
newPositiveStackPixel: newStack,
newNegativeStackPixel: negativeStackPixel,
};
}
if (isNegative) {
const newStack = negativeStackPixel - valuePixelDelta;
const clampedCurrent = clampToAxis(negativeStackPixel);
const clampedNew = clampToAxis(newStack);
return {
x: negativeStackPixel - valuePixelDelta,
x: clampedNew,
y: categoryPosition,
width: valuePixelDelta,
width: clampedCurrent - clampedNew,
height: barThickness,
newPositiveStackPixel: positiveStackPixel,
newNegativeStackPixel: negativeStackPixel - valuePixelDelta,
newNegativeStackPixel: newStack,
};
}
const newStack = positiveStackPixel + valuePixelDelta;
const clampedCurrent = clampToAxis(positiveStackPixel);
const clampedNew = clampToAxis(newStack);
return {
x: positiveStackPixel,
x: clampedCurrent,
y: categoryPosition,
width: valuePixelDelta,
width: clampedNew - clampedCurrent,
height: barThickness,
newPositiveStackPixel: positiveStackPixel + valuePixelDelta,
newPositiveStackPixel: newStack,
newNegativeStackPixel: negativeStackPixel,
};
};
@@ -46,4 +46,30 @@ describe('computeValueTickValues', () => {
expect(result.domain.min).toBeLessThanOrEqual(-100);
expect(result.domain.max).toBeGreaterThanOrEqual(-10);
});
it('should preserve explicit domain bounds when requested', () => {
const result = computeValueTickValues({
minimum: 0,
maximum: 10,
tickCount: 2,
preserveDomainBounds: true,
});
expect(result.domain).toEqual({ min: 0, max: 10 });
expect(result.tickValues[0]).toBe(0);
expect(result.tickValues[result.tickValues.length - 1]).toBe(10);
expect(result.tickValues.every((tick) => tick >= 0 && tick <= 10)).toBe(
true,
);
});
it('should still allow domain expansion when preserving bounds is disabled', () => {
const result = computeValueTickValues({
minimum: 0,
maximum: 10,
tickCount: 2,
});
expect(result.domain.max).toBeGreaterThan(10);
});
});
@@ -24,6 +24,42 @@ describe('getChartMarginsFromText', () => {
expect(result.right).toBe(18);
});
it('clamps right margin when rightTickLabels are very long', () => {
const longLabel = 'X'.repeat(200);
const result = getChartMarginsFromText({
tickFontSize: 12,
legendFontSize: 12,
bottomTickLabels: ['a'],
leftTickLabels: ['b'],
rightTickLabels: [longLabel],
xAxisLabel: 'x',
yAxisLabel: 'y',
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
bottomLegendOffset: 0,
});
expect(result.right).toBe(TEXT_MARGIN_LIMITS.max.right);
});
it('increases right margin when rightTickLabels are provided', () => {
const topRightBase = Math.ceil(12 * 1.5);
const result = getChartMarginsFromText({
tickFontSize: 12,
legendFontSize: 12,
bottomTickLabels: ['a'],
leftTickLabels: ['b'],
rightTickLabels: ['$10,000'],
xAxisLabel: 'x',
yAxisLabel: 'y',
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
bottomLegendOffset: 0,
});
expect(result.right).toBeGreaterThan(topRightBase);
});
it('uses bottom legend offset when it exceeds tick and label blocks', () => {
const bottomLegendOffset = 100;
const result = getChartMarginsFromText({
@@ -4,6 +4,7 @@ import { getChartMarginsFromText } from '@/page-layout/widgets/graph/utils/getCh
type ChartMarginInputs = {
bottomTickLabels?: string[];
leftTickLabels?: string[];
rightTickLabels?: string[];
};
type ComputeChartMarginsParams<TTickConfig, TValueTickResult> = {
@@ -76,6 +77,7 @@ export const computeChartMargins = <TTickConfig, TValueTickResult>({
legendFontSize,
bottomTickLabels: provisionalMarginInputs.bottomTickLabels,
leftTickLabels: provisionalMarginInputs.leftTickLabels,
rightTickLabels: provisionalMarginInputs.rightTickLabels,
xAxisLabel,
yAxisLabel,
tickRotation: getTickRotation(provisionalTickConfiguration),
@@ -98,6 +100,7 @@ export const computeChartMargins = <TTickConfig, TValueTickResult>({
legendFontSize,
bottomTickLabels: marginInputs.bottomTickLabels,
leftTickLabels: marginInputs.leftTickLabels,
rightTickLabels: marginInputs.rightTickLabels,
xAxisLabel,
yAxisLabel,
tickRotation: getTickRotation(tickConfiguration),
@@ -25,10 +25,12 @@ export const computeValueTickValues = ({
minimum,
maximum,
tickCount,
preserveDomainBounds = false,
}: {
minimum: number;
maximum: number;
tickCount: number;
preserveDomainBounds?: boolean;
}): {
tickValues: number[];
domain: { min: number; max: number };
@@ -52,6 +54,42 @@ export const computeValueTickValues = ({
};
}
if (preserveDomainBounds) {
if (minimum > maximum) {
return {
tickValues: [minimum],
domain: { min: minimum, max: minimum },
};
}
const tickValues: number[] = [Number(minimum.toFixed(12))];
const firstInteriorTick =
Math.ceil(minimum / niceStepInterval) * niceStepInterval;
for (
let tickValue = firstInteriorTick;
tickValue <= maximum + niceStepInterval / 2;
tickValue += niceStepInterval
) {
const roundedTickValue = Number(tickValue.toFixed(12));
if (roundedTickValue > minimum && roundedTickValue < maximum) {
tickValues.push(roundedTickValue);
}
}
const roundedMaximum = Number(maximum.toFixed(12));
if (tickValues[tickValues.length - 1] !== roundedMaximum) {
tickValues.push(roundedMaximum);
}
return {
tickValues,
domain: { min: minimum, max: maximum },
};
}
const niceMinimum = Math.floor(minimum / niceStepInterval) * niceStepInterval;
const niceMaximum = Math.ceil(maximum / niceStepInterval) * niceStepInterval;
const tickValues: number[] = [];
@@ -22,6 +22,7 @@ export const getChartMarginsFromText = ({
legendFontSize,
bottomTickLabels,
leftTickLabels,
rightTickLabels,
xAxisLabel,
yAxisLabel,
tickRotation,
@@ -31,6 +32,7 @@ export const getChartMarginsFromText = ({
legendFontSize?: number;
bottomTickLabels?: string[];
leftTickLabels?: string[];
rightTickLabels?: string[];
xAxisLabel?: string;
yAxisLabel?: string;
tickRotation: number;
@@ -40,6 +42,7 @@ export const getChartMarginsFromText = ({
const bottomMaxLabelLength = getMaxLabelLength(bottomTickLabels);
const leftMaxLabelLength = getMaxLabelLength(leftTickLabels);
const rightMaxLabelLength = getMaxLabelLength(rightTickLabels);
const tickPaddingExtra = xAxisLabel ? TEXT_MARGIN_EXTRAS.tickPaddingExtra : 0;
const bottomTickHeight =
@@ -92,6 +95,17 @@ export const getChartMarginsFromText = ({
TEXT_MARGIN_LIMITS.max.left,
);
const rightTickWidth =
rightMaxLabelLength > 0
? estimateLabelWidth(rightMaxLabelLength, tickFontSize)
: 0;
const rightTicksBlock =
rightTickWidth > 0
? rightTickWidth +
COMMON_CHART_CONSTANTS.TICK_PADDING +
TEXT_MARGIN_EXTRAS.rightTickExtra
: 0;
const topRightBase = Math.ceil(tickFontSize * 1.5);
return {
@@ -101,7 +115,7 @@ export const getChartMarginsFromText = ({
TEXT_MARGIN_LIMITS.max.top,
),
right: clamp(
topRightBase,
Math.max(topRightBase, rightTicksBlock),
TEXT_MARGIN_LIMITS.min.right,
TEXT_MARGIN_LIMITS.max.right,
),
@@ -174,71 +174,6 @@ describe('BarChartDataService', () => {
expect(result.data[2].amount).toBe(60);
});
it('should filter by rangeMin when cumulative', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
{ groupByDimensionValues: ['b'], aggregateValue: 10 },
{ groupByDimensionValues: ['c'], aggregateValue: 10 },
]);
const result = await service.getBarChartData({
workspaceId,
objectMetadataId,
configuration: {
...baseConfiguration,
isCumulative: true,
rangeMin: 15,
} as any,
authContext: mockAuthContext,
});
expect(result.data.map((d) => d.amount)).toEqual([]);
});
it('should filter by rangeMax when cumulative', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
{ groupByDimensionValues: ['b'], aggregateValue: 20 },
{ groupByDimensionValues: ['c'], aggregateValue: 30 },
]);
const result = await service.getBarChartData({
workspaceId,
objectMetadataId,
configuration: {
...baseConfiguration,
isCumulative: true,
rangeMax: 25,
} as any,
authContext: mockAuthContext,
});
expect(result.data.map((d) => d.amount)).toEqual([10, 30]);
});
it('should filter by both rangeMin and rangeMax when cumulative', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 5 },
{ groupByDimensionValues: ['b'], aggregateValue: 15 },
{ groupByDimensionValues: ['c'], aggregateValue: 25 },
{ groupByDimensionValues: ['d'], aggregateValue: 35 },
]);
const result = await service.getBarChartData({
workspaceId,
objectMetadataId,
configuration: {
...baseConfiguration,
isCumulative: true,
rangeMin: 10,
rangeMax: 30,
} as any,
authContext: mockAuthContext,
});
expect(result.data.map((d) => d.amount)).toEqual([15, 40]);
});
it('should handle null values when omitNullValues is true', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: [null], aggregateValue: 5 },
@@ -404,34 +339,6 @@ describe('BarChartDataService', () => {
expect(result.data[2]['Closed']).toBe(60);
});
it('should filter stacked two-dimensional cumulative data by rangeMax', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a', 'open'], aggregateValue: 10 },
{ groupByDimensionValues: ['a', 'closed'], aggregateValue: 20 },
{ groupByDimensionValues: ['b', 'open'], aggregateValue: 5 },
{ groupByDimensionValues: ['b', 'closed'], aggregateValue: 5 },
{ groupByDimensionValues: ['c', 'open'], aggregateValue: 50 },
{ groupByDimensionValues: ['c', 'closed'], aggregateValue: 30 },
]);
const result = await service.getBarChartData({
workspaceId,
objectMetadataId,
configuration: {
...twoDimConfiguration,
isCumulative: true,
rangeMax: 60,
} as any,
authContext: mockAuthContext,
});
expect(result.data.length).toBe(2);
expect(result.data[0]['Open']).toBe(10);
expect(result.data[0]['Closed']).toBe(20);
expect(result.data[1]['Open']).toBe(15);
expect(result.data[1]['Closed']).toBe(25);
});
it('should order keys correctly despite unordered raw results', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['Oct 16', 'open'], aggregateValue: 100 },
@@ -174,48 +174,6 @@ describe('LineChartDataService', () => {
]);
});
it('should filter by rangeMin when cumulative', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
{ groupByDimensionValues: ['b'], aggregateValue: 10 },
{ groupByDimensionValues: ['c'], aggregateValue: 10 },
]);
const result = await service.getLineChartData({
workspaceId,
objectMetadataId,
configuration: {
...baseConfiguration,
isCumulative: true,
rangeMin: 15,
} as any,
authContext: mockAuthContext,
});
expect(result.series[0].data.map((d) => d.y)).toEqual([]);
});
it('should filter by rangeMax when cumulative', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
{ groupByDimensionValues: ['b'], aggregateValue: 20 },
{ groupByDimensionValues: ['c'], aggregateValue: 30 },
]);
const result = await service.getLineChartData({
workspaceId,
objectMetadataId,
configuration: {
...baseConfiguration,
isCumulative: true,
rangeMax: 25,
} as any,
authContext: mockAuthContext,
});
expect(result.series[0].data.map((d) => d.y)).toEqual([10, 30]);
});
it('should handle null y values by keeping running total', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
@@ -387,47 +345,6 @@ describe('LineChartDataService', () => {
expect(seriesB?.data[1].y).toBe(300);
});
it('should filter stacked two-dimensional cumulative data by rangeMax', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 10 },
{ groupByDimensionValues: ['2024-01-01', 'B'], aggregateValue: 20 },
{ groupByDimensionValues: ['2024-02-01', 'A'], aggregateValue: 5 },
{ groupByDimensionValues: ['2024-02-01', 'B'], aggregateValue: 5 },
{ groupByDimensionValues: ['2024-03-01', 'A'], aggregateValue: 50 },
{ groupByDimensionValues: ['2024-03-01', 'B'], aggregateValue: 30 },
]);
const result = await service.getLineChartData({
workspaceId,
objectMetadataId,
configuration: {
...twoDimConfiguration,
isStacked: true,
isCumulative: true,
rangeMax: 60,
} as any,
authContext: mockAuthContext,
});
const seriesA = result.series.find((s) => s.label === 'A');
const seriesB = result.series.find((s) => s.label === 'B');
expect(seriesA).toBeDefined();
expect(seriesB).toBeDefined();
expect(seriesA!.data).toHaveLength(2);
expect(seriesB!.data).toHaveLength(2);
const seriesAXValues = seriesA!.data.map((point) => point.x);
const seriesBXValues = seriesB!.data.map((point) => point.x);
expect(seriesAXValues).toEqual(seriesBXValues);
expect(new Set(seriesAXValues).size).toBe(2);
expect(seriesA!.data[0].y).toBe(10);
expect(seriesA!.data[1].y).toBe(15);
expect(seriesB!.data[0].y).toBe(20);
expect(seriesB!.data[1].y).toBe(25);
});
it('should handle empty results', async () => {
mockExecuteGroupByQuery.mockResolvedValue([]);
@@ -30,8 +30,6 @@ import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/fiel
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
import { RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
import { applyGapFilling } from 'src/modules/dashboard/chart-data/utils/apply-gap-filling.util';
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
import { filterTwoDimensionalDataByRange } from 'src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util';
import { getAggregateOperationLabel } from 'src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util';
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
@@ -230,21 +228,12 @@ export class BarChartDataService {
)
: rawResults;
const rangeFilteredResults =
isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax)
? filterByRange(
filteredResults,
configuration.rangeMin,
configuration.rangeMax,
)
: filteredResults;
const isDescOrder =
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
data: rangeFilteredResults,
data: filteredResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -370,22 +359,12 @@ export class BarChartDataService {
configuration.groupMode ?? BarChartGroupMode.STACKED;
const isStacked = effectiveGroupMode === BarChartGroupMode.STACKED;
const rangeFilteredResults =
!isStacked &&
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
? filterByRange(
filteredResults,
configuration.rangeMin,
configuration.rangeMax,
)
: filteredResults;
const isDescOrder =
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
data: rangeFilteredResults,
data: filteredResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -508,18 +487,6 @@ export class BarChartDataService {
}
}
if (
isStacked &&
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
) {
finalLimitedData = filterTwoDimensionalDataByRange(
finalLimitedData,
limitedKeys,
configuration.rangeMin,
configuration.rangeMax,
);
}
const finalData = configuration.isCumulative
? this.applyCumulativeTwoDimensional(finalLimitedData, limitedKeys)
: finalLimitedData;
@@ -28,8 +28,6 @@ import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/fiel
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
import { RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
import { applyGapFilling } from 'src/modules/dashboard/chart-data/utils/apply-gap-filling.util';
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
import { filterLineChartXValuesByRange } from 'src/modules/dashboard/chart-data/utils/filter-line-chart-x-values-by-range.util';
import { getAggregateOperationLabel } from 'src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util';
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
@@ -237,21 +235,12 @@ export class LineChartDataService {
)
: rawResults;
const rangeFilteredResults =
isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax)
? filterByRange(
filteredResults,
configuration.rangeMin,
configuration.rangeMax,
)
: filteredResults;
const isDescOrder =
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
data: rangeFilteredResults,
data: filteredResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -366,22 +355,12 @@ export class LineChartDataService {
const isStacked = configuration.isStacked ?? false;
const rangeFilteredResults =
!isStacked &&
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
? filterByRange(
filteredResults,
configuration.rangeMin,
configuration.rangeMax,
)
: filteredResults;
const isDescOrder =
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
data: rangeFilteredResults,
data: filteredResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -496,23 +475,11 @@ export class LineChartDataService {
const limitedSeriesIds = sortedSeriesIds.slice(0, maxSeries);
const filteredXValues =
isStacked &&
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
? filterLineChartXValuesByRange(
limitedXValues,
seriesMap,
limitedSeriesIds,
configuration.rangeMin,
configuration.rangeMax,
)
: limitedXValues;
const series = limitedSeriesIds.map((seriesId) => {
const xToYMap = seriesMap.get(seriesId) ?? new Map();
const prefixedSeriesId = `${seriesIdPrefix}${seriesId}`;
let dataPoints = filteredXValues.map((xValue) => ({
let dataPoints = limitedXValues.map((xValue) => ({
x: xValue,
y: xToYMap.get(xValue) ?? 0,
}));
@@ -1,165 +0,0 @@
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
describe('filterByRange', () => {
describe('rangeMin filtering', () => {
it('should filter out results below rangeMin', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, 1000);
expect(filtered).toHaveLength(2);
expect(filtered[0].aggregateValue).toBe(1500);
expect(filtered[1].aggregateValue).toBe(2500);
});
it('should include values equal to rangeMin', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
];
const filtered = filterByRange(results, 1000);
expect(filtered).toHaveLength(2);
expect(filtered[0].aggregateValue).toBe(1000);
expect(filtered[1].aggregateValue).toBe(1500);
});
});
describe('rangeMax filtering', () => {
it('should filter out results above rangeMax', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, undefined, 2000);
expect(filtered).toHaveLength(2);
expect(filtered[0].aggregateValue).toBe(500);
expect(filtered[1].aggregateValue).toBe(1500);
});
it('should include values equal to rangeMax', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 1500 },
{ groupByDimensionValues: ['B'], aggregateValue: 2000 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, undefined, 2000);
expect(filtered).toHaveLength(2);
expect(filtered[0].aggregateValue).toBe(1500);
expect(filtered[1].aggregateValue).toBe(2000);
});
});
describe('combined range filtering', () => {
it('should keep only results within range', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, 1000, 2000);
expect(filtered).toHaveLength(1);
expect(filtered[0].aggregateValue).toBe(1500);
});
it('should include boundary values', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
{ groupByDimensionValues: ['D'], aggregateValue: 2000 },
{ groupByDimensionValues: ['E'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, 1000, 2000);
expect(filtered).toHaveLength(3);
expect(filtered[0].aggregateValue).toBe(1000);
expect(filtered[1].aggregateValue).toBe(1500);
expect(filtered[2].aggregateValue).toBe(2000);
});
});
describe('no filters', () => {
it('should return all results when no range is specified', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results);
expect(filtered).toEqual(results);
});
it('should return all results when ranges are null', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, null, null);
expect(filtered).toEqual(results);
});
it('should return all results when ranges are undefined', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
];
const filtered = filterByRange(results, undefined, undefined);
expect(filtered).toEqual(results);
});
});
describe('edge cases', () => {
it('should handle empty results array', () => {
const filtered = filterByRange([], 1000, 2000);
expect(filtered).toEqual([]);
});
it('should handle zero values', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: 0 },
{ groupByDimensionValues: ['B'], aggregateValue: 100 },
];
const filtered = filterByRange(results, 0);
expect(filtered).toHaveLength(2);
});
it('should handle negative values', () => {
const results = [
{ groupByDimensionValues: ['A'], aggregateValue: -100 },
{ groupByDimensionValues: ['B'], aggregateValue: 0 },
{ groupByDimensionValues: ['C'], aggregateValue: 100 },
];
const filtered = filterByRange(results, -50, 50);
expect(filtered).toHaveLength(1);
expect(filtered[0].aggregateValue).toBe(0);
});
});
});
@@ -1,225 +0,0 @@
import { filterTwoDimensionalDataByRange } from 'src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util';
describe('filterTwoDimensionalDataByRange', () => {
describe('no filters', () => {
it('should return all data when no range is specified', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 200 },
{ category: 'B', seriesA: 300, seriesB: 400 },
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys);
expect(filtered).toEqual(data);
});
it('should return all data when ranges are null', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 200 },
{ category: 'B', seriesA: 300, seriesB: 400 },
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, null, null);
expect(filtered).toEqual(data);
});
it('should return all data when ranges are undefined', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 200 },
{ category: 'B', seriesA: 300, seriesB: 400 },
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(
data,
keys,
undefined,
undefined,
);
expect(filtered).toEqual(data);
});
});
describe('rangeMin filtering', () => {
it('should filter out data with total below rangeMin', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
expect(filtered).toHaveLength(2);
expect(filtered[0].category).toBe('B');
expect(filtered[1].category).toBe('C');
});
it('should include data with total equal to rangeMin', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 200, seriesB: 200 }, // total: 400
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
expect(filtered).toHaveLength(2);
expect(filtered[0].category).toBe('B');
expect(filtered[1].category).toBe('C');
});
});
describe('rangeMax filtering', () => {
it('should filter out data with total above rangeMax', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(
data,
keys,
undefined,
600,
);
expect(filtered).toHaveLength(2);
expect(filtered[0].category).toBe('A');
expect(filtered[1].category).toBe('B');
});
it('should include data with total equal to rangeMax', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(
data,
keys,
undefined,
500,
);
expect(filtered).toHaveLength(2);
expect(filtered[0].category).toBe('A');
expect(filtered[1].category).toBe('B');
});
});
describe('combined range filtering', () => {
it('should keep only data within range', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe('B');
});
it('should include boundary values', () => {
const data = [
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
{ category: 'B', seriesA: 150, seriesB: 150 }, // total: 300
{ category: 'C', seriesA: 300, seriesB: 200 }, // total: 500
{ category: 'D', seriesA: 400, seriesB: 400 }, // total: 800
{ category: 'E', seriesA: 500, seriesB: 500 }, // total: 1000
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
expect(filtered).toHaveLength(3);
expect(filtered[0].category).toBe('B');
expect(filtered[1].category).toBe('C');
expect(filtered[2].category).toBe('D');
});
});
describe('edge cases', () => {
it('should handle empty data array', () => {
const filtered = filterTwoDimensionalDataByRange(
[],
['seriesA'],
100,
200,
);
expect(filtered).toEqual([]);
});
it('should handle empty keys array', () => {
const data = [
{ category: 'A', seriesA: 100 },
{ category: 'B', seriesA: 200 },
];
// With empty keys, total is always 0
const filtered = filterTwoDimensionalDataByRange(data, [], 0, 0);
expect(filtered).toHaveLength(2);
});
it('should ignore non-numeric values when summing', () => {
const data = [
{
category: 'A',
seriesA: 100,
seriesB: 'invalid' as unknown as number,
},
{ category: 'B', seriesA: 300, seriesB: 200 },
];
const keys = ['seriesA', 'seriesB'];
// category A total is 100 (invalid is treated as 0)
// category B total is 500
const filtered = filterTwoDimensionalDataByRange(data, keys, 200);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe('B');
});
it('should handle zero values', () => {
const data = [
{ category: 'A', seriesA: 0, seriesB: 0 }, // total: 0
{ category: 'B', seriesA: 0, seriesB: 100 }, // total: 100
];
const keys = ['seriesA', 'seriesB'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 0, 50);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe('A');
});
it('should handle single key', () => {
const data = [
{ category: 'A', value: 100 },
{ category: 'B', value: 300 },
{ category: 'C', value: 500 },
];
const keys = ['value'];
const filtered = filterTwoDimensionalDataByRange(data, keys, 200, 400);
expect(filtered).toHaveLength(1);
expect(filtered[0].category).toBe('B');
});
});
});
@@ -1,23 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
export const filterByRange = (
results: GroupByRawResult[],
rangeMin?: number | null,
rangeMax?: number | null,
): GroupByRawResult[] => {
return results.filter((result) => {
const value = result.aggregateValue;
if (isDefined(rangeMin) && value < rangeMin) {
return false;
}
if (isDefined(rangeMax) && value > rangeMax) {
return false;
}
return true;
});
};
@@ -1,33 +0,0 @@
import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const filterLineChartXValuesByRange = (
xValues: string[],
seriesMap: Map<string, Map<string, number>>,
seriesIds: string[],
rangeMin?: number | null,
rangeMax?: number | null,
): string[] => {
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
return xValues;
}
return xValues.filter((xValue) => {
const totalValue = seriesIds.reduce((sum, seriesId) => {
const xToYMap = seriesMap.get(seriesId) ?? new Map();
const yValue = xToYMap.get(xValue) ?? 0;
return sum + (isNumber(yValue) ? yValue : 0);
}, 0);
if (isDefined(rangeMin) && totalValue < rangeMin) {
return false;
}
if (isDefined(rangeMax) && totalValue > rangeMax) {
return false;
}
return true;
});
};
@@ -1,33 +0,0 @@
import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const filterTwoDimensionalDataByRange = <
T extends Record<string, string | number>,
>(
data: T[],
keys: string[],
rangeMin?: number | null,
rangeMax?: number | null,
): T[] => {
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
return data;
}
return data.filter((datum) => {
const totalValue = keys.reduce((sum, key) => {
const value = datum[key];
return sum + (isNumber(value) ? value : 0);
}, 0);
if (isDefined(rangeMin) && totalValue < rangeMin) {
return false;
}
if (isDefined(rangeMax) && totalValue > rangeMax) {
return false;
}
return true;
});
};