nitin
2025-11-13 17:05:08 +05:30
committed by GitHub
parent eee35e5e25
commit fddac24e7f
71 changed files with 2375 additions and 790 deletions
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
@@ -0,0 +1,131 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with date field and granularity 1`] = `
{
"groupBy": [
{
"createdAt": {
"granularity": "MONTH",
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with date field and granularity 1`] = `
{
"groupBy": [
{
"createdAt": {
"granularity": "MONTH",
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with two groupBy fields (multi-series) 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
@@ -0,0 +1,75 @@
import { calculateValueRangeFromValues } from '../calculateValueRangeFromValues';
describe('calculateValueRangeFromValues', () => {
it('should return minimum=0 and maximum=highest value for all positive values', () => {
const values = [5, 10, 15, 20];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 20 });
});
it('should return minimum=lowest value and maximum=0 for all negative values', () => {
const values = [-5, -10, -15, -20];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -20, maximum: 0 });
});
it('should include zero in range when values cross zero', () => {
const values = [-10, -5, 5, 10];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -10, maximum: 10 });
});
it('should handle empty array', () => {
const values: number[] = [];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
it('should ignore NaN values', () => {
const values = [5, NaN, 10, NaN, 15];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 15 });
});
it('should handle all NaN values', () => {
const values = [NaN, NaN, NaN];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
it('should handle single value', () => {
const values = [42];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 42 });
});
it('should handle single negative value', () => {
const values = [-42];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: -42, maximum: 0 });
});
it('should handle zero value', () => {
const values = [0];
const result = calculateValueRangeFromValues(values);
expect(result).toEqual({ minimum: 0, maximum: 0 });
});
});
@@ -0,0 +1,92 @@
import { computeChartCategoryTickValues } from '../computeChartCategoryTickValues';
describe('computeChartCategoryTickValues', () => {
it('should return all values when available size allows', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual(['A', 'B', 'C', 'D', 'E']);
});
it('should return subset of values when space is limited', () => {
const values = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const result = computeChartCategoryTickValues({
availableSize: 400,
minimumSizePerTick: 100,
values,
});
expect(result.length).toBe(4);
expect(result).toEqual(['A', 'C', 'F', 'H']);
});
it('should return empty array when available size is zero', () => {
const values = ['A', 'B', 'C'];
const result = computeChartCategoryTickValues({
availableSize: 0,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
it('should return empty array when available size is negative', () => {
const values = ['A', 'B', 'C'];
const result = computeChartCategoryTickValues({
availableSize: -100,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
it('should return empty array when values array is empty', () => {
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values: [],
});
expect(result).toEqual([]);
});
it('should handle single value', () => {
const values = ['A'];
const result = computeChartCategoryTickValues({
availableSize: 500,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual(['A']);
});
it('should work with numeric values', () => {
const values = [1, 2, 3, 4, 5, 6, 7, 8];
const result = computeChartCategoryTickValues({
availableSize: 300,
minimumSizePerTick: 100,
values,
});
expect(result.length).toBe(3);
expect(result).toEqual([1, 5, 8]);
});
it('should handle very small available size', () => {
const values = ['A', 'B', 'C', 'D', 'E'];
const result = computeChartCategoryTickValues({
availableSize: 50,
minimumSizePerTick: 100,
values,
});
expect(result).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import { generateChartAggregateFilterKey } from '../generateChartAggregateFilterKey';
describe('generateChartAggregateFilterKey', () => {
it('should generate key with all values provided', () => {
const result = generateChartAggregateFilterKey(0, 100, true);
expect(result).toBe('0-100-true');
});
it('should generate key with negative values', () => {
const result = generateChartAggregateFilterKey(-50, 50, false);
expect(result).toBe('-50-50-false');
});
it('should handle undefined values as empty strings', () => {
const result = generateChartAggregateFilterKey(
undefined,
undefined,
undefined,
);
expect(result).toBe('--');
});
it('should handle null values as empty strings', () => {
const result = generateChartAggregateFilterKey(null, null, null);
expect(result).toBe('--');
});
it('should handle mixed defined and undefined values', () => {
const result = generateChartAggregateFilterKey(10, undefined, true);
expect(result).toBe('10--true');
});
it('should handle zero as valid value', () => {
const result = generateChartAggregateFilterKey(0, 0, false);
expect(result).toBe('0-0-false');
});
it('should handle no arguments', () => {
const result = generateChartAggregateFilterKey();
expect(result).toBe('--');
});
it('should handle decimal values', () => {
const result = generateChartAggregateFilterKey(10.5, 99.9, true);
expect(result).toBe('10.5-99.9-true');
});
});
@@ -1,132 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AggregateOperations,
type BarChartConfiguration,
FieldMetadataType,
GraphType,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration';
describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [
{
id: 'field-1',
name: 'stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'owner',
type: FieldMetadataType.RELATION,
},
{
id: 'field-3',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
{
id: 'field-4',
name: 'name',
type: FieldMetadataType.FULL_NAME,
},
],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as BarChartConfiguration;
describe('Vertical bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Horizontal bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
it('should throw error when primary axis field not found', () => {
expect(() =>
generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
});
@@ -0,0 +1,244 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AggregateOperations,
type BarChartConfiguration,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromChartConfiguration } from '../generateGroupByQueryVariablesFromChartConfiguration';
describe('generateGroupByQueryVariablesFromChartConfiguration', () => {
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [
{
id: 'field-1',
name: 'stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-2',
name: 'owner',
type: FieldMetadataType.RELATION,
},
{
id: 'field-3',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
{
id: 'field-4',
name: 'name',
type: FieldMetadataType.FULL_NAME,
},
],
} as ObjectMetadataItem;
const buildBarChartConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as BarChartConfiguration;
const buildLineChartConfiguration = (
overrides: Partial<LineChartConfiguration>,
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as LineChartConfiguration;
describe('Bar Chart Configuration', () => {
describe('Vertical bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with date field and granularity', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-3',
primaryAxisGroupBySubFieldName: null,
primaryAxisDateGranularity: 'MONTH' as any,
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Horizontal bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
});
describe('Line Chart Configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields (multi-series)', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with date field and granularity', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-3',
primaryAxisGroupBySubFieldName: null,
primaryAxisDateGranularity: 'MONTH' as any,
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
describe('Error handling', () => {
it('should throw error when primary axis field not found (bar chart)', () => {
expect(() =>
generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
it('should throw error when primary axis field not found (line chart)', () => {
expect(() =>
generateGroupByQueryVariablesFromChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
chartConfiguration: buildLineChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
});
});
@@ -0,0 +1,234 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import {
AggregateOperations,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { transformOneDimensionalGroupByToLineChartData } from '../transformOneDimensionalGroupByToLineChartData';
describe('transformOneDimensionalGroupByToLineChartData', () => {
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockDateGroupByField: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockDateGroupByField],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
graphType: GraphType.LINE,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Categorical X-axis', () => {
it('should transform simple categorical groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Qualification'],
sumAmount: 150000,
},
{
groupByDimensionValues: ['Proposal'],
sumAmount: 280000,
},
{
groupByDimensionValues: ['Closed Won'],
sumAmount: 450000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0]).toMatchObject({
id: 'amount',
label: 'Amount',
color: 'blue',
});
expect(result.series[0].data).toEqual([
{ x: 'Qualification', y: 150000 },
{ x: 'Proposal', y: 280000 },
{ x: 'Closed Won', y: 450000 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should filter out null aggregate values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['Stage B'],
sumAmount: null,
},
{
groupByDimensionValues: ['Stage C'],
sumAmount: 200,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 100 },
{ x: 'Stage C', y: 200 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Time-series X-axis', () => {
it('should transform date-based groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-02'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-03'],
sumAmount: 60000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockDateGroupByField,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'created-at-field',
primaryAxisDateGranularity: 'MONTH' as any,
}),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y', 50000);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should use default color when not specified', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [
{
groupByDimensionValues: ['Test'],
sumAmount: 100,
},
],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({ color: undefined }),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].color).toBeDefined();
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['Stage B'],
_count: 10,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 5 },
{ x: 'Stage B', y: 10 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -0,0 +1,310 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import {
AggregateOperations,
FieldMetadataType,
GraphType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
import { transformTwoDimensionalGroupByToLineChartData } from '../transformTwoDimensionalGroupByToLineChartData';
describe('transformTwoDimensionalGroupByToLineChartData', () => {
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockGroupByFieldY: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockGroupByFieldY],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
graphType: GraphType.LINE,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'created-at-field',
secondaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Multi-series transformation', () => {
it('should create multiple series from 2D groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Qualification'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-01', 'Proposal'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-02', 'Qualification'],
sumAmount: 60000,
},
{
groupByDimensionValues: ['2024-02', 'Proposal'],
sumAmount: 90000,
},
{
groupByDimensionValues: ['2024-03', 'Qualification'],
sumAmount: 55000,
},
{
groupByDimensionValues: ['2024-03', 'Proposal'],
sumAmount: 80000,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(2);
expect(result.series[0]).toMatchObject({
id: expect.any(String),
label: expect.any(String),
color: 'blue',
});
expect(result.series[0].data).toHaveLength(3);
expect(result.series[1].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y');
result.series.forEach((series) => {
series.data.forEach((point) => {
expect(point).toHaveProperty('x');
expect(point).toHaveProperty('y');
});
});
expect(result.hasTooManyGroups).toBe(false);
});
it('should preserve backend ordering of data points', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-03', 'Stage A'],
sumAmount: 300,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
const series = result.series[0];
expect(series.data[0].y).toBe(100);
expect(series.data[1].y).toBe(200);
expect(series.data[2].y).toBe(300);
expect(result.hasTooManyGroups).toBe(false);
});
it('should normalize sparse data (all series share same X values, with 0 for missing)', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-01', 'Stage B'],
sumAmount: 150,
},
{
groupByDimensionValues: ['2024-03', 'Stage B'],
sumAmount: 250,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
const stageA = result.series.find((s) => s.id === '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');
expect(stageB?.data).toHaveLength(3);
expect(stageB?.data[0].y).toBe(150);
expect(stageB?.data[1].y).toBe(0);
expect(stageB?.data[2].y).toBe(250);
expect(result.hasTooManyGroups).toBe(false);
});
it('should filter out null aggregate values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: null,
},
{
groupByDimensionValues: ['2024-03', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data).toHaveLength(2);
expect(result.series[0].data.map((d) => d.y)).toEqual([100, 200]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should skip results with missing dimension values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(1);
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01', 'Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['2024-02', 'Stage A'],
_count: 10,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
});
expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -0,0 +1,22 @@
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
export const calculateValueRangeFromValues = (
values: number[],
): ChartValueRange => {
let minimumValue = 0;
let maximumValue = 0;
for (const value of values) {
if (isNaN(value)) {
continue;
}
minimumValue = Math.min(minimumValue, value);
maximumValue = Math.max(maximumValue, value);
}
minimumValue = Math.min(minimumValue, 0);
maximumValue = Math.max(maximumValue, 0);
return { minimum: minimumValue, maximum: maximumValue };
};
@@ -0,0 +1,16 @@
export const computeCategoryTickValues = (
numberOfTicks: number,
totalValues: number,
): number[] => {
if (numberOfTicks <= 0) return [];
if (numberOfTicks === 1) return [0];
if (numberOfTicks >= totalValues)
return Array.from({ length: totalValues }, (_, i) => i);
const step = (totalValues - 1) / (numberOfTicks - 1);
return Array.from({ length: numberOfTicks }, (_, i) => {
const index = Math.min(Math.round(i * step), totalValues - 1);
return index;
});
};
@@ -0,0 +1,20 @@
import { computeCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeCategoryTickValues';
export const computeChartCategoryTickValues = ({
availableSize,
minimumSizePerTick,
values,
}: {
availableSize: number;
minimumSizePerTick: number;
values: (string | number)[];
}): (string | number)[] => {
if (availableSize <= 0 || values.length === 0) {
return [];
}
const numberOfTicks = Math.floor(availableSize / minimumSizePerTick);
const tickIndices = computeCategoryTickValues(numberOfTicks, values.length);
return tickIndices.map((index) => values[index]);
};
@@ -0,0 +1,7 @@
export const generateChartAggregateFilterKey = (
rangeMin?: number | null,
rangeMax?: number | null,
omitNullValues?: boolean | null,
): string => {
return `${rangeMin ?? ''}-${rangeMax ?? ''}-${omitNullValues ?? ''}`;
};
@@ -1,4 +1,5 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import {
type AggregateOrderByWithGroupByField,
@@ -7,29 +8,27 @@ import {
type ObjectRecordOrderByWithGroupByDateField,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
import { buildGroupByFieldObject } from './buildGroupByFieldObject';
export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
export const generateGroupByQueryVariablesFromChartConfiguration = ({
objectMetadataItem,
barChartConfiguration,
chartConfiguration,
aggregateOperation,
}: {
objectMetadataItem: ObjectMetadataItem;
barChartConfiguration: BarChartConfiguration;
chartConfiguration: GroupByChartConfiguration;
aggregateOperation?: string;
}) => {
const groupByFieldXId =
barChartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldYId =
barChartConfiguration.secondaryAxisGroupByFieldMetadataId;
chartConfiguration.secondaryAxisGroupByFieldMetadataId;
const groupBySubFieldNameX =
barChartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
chartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
const groupBySubFieldNameY =
barChartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
chartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
const groupByFieldX = objectMetadataItem.fields.find(
(field) => field.id === groupByFieldXId,
@@ -54,7 +53,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
field: groupByFieldX,
subFieldName: groupBySubFieldNameX,
dateGranularity:
barChartConfiguration.primaryAxisDateGranularity ?? undefined,
chartConfiguration.primaryAxisDateGranularity ?? undefined,
}),
);
@@ -64,8 +63,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
field: groupByFieldY,
subFieldName: groupBySubFieldNameY,
dateGranularity:
barChartConfiguration.secondaryAxisGroupByDateGranularity ??
undefined,
chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined,
}),
);
}
@@ -77,33 +75,31 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
| ObjectRecordOrderByForCompositeField
> = [];
if (isDefined(barChartConfiguration.primaryAxisOrderBy)) {
if (isDefined(chartConfiguration.primaryAxisOrderBy)) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.primaryAxisOrderBy,
graphOrderBy: chartConfiguration.primaryAxisOrderBy,
groupByField: groupByFieldX,
groupBySubFieldName:
barChartConfiguration.primaryAxisGroupBySubFieldName,
groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity:
barChartConfiguration.primaryAxisDateGranularity ?? undefined,
chartConfiguration.primaryAxisDateGranularity ?? undefined,
}),
);
}
if (
isDefined(groupByFieldY) &&
isDefined(barChartConfiguration.secondaryAxisOrderBy)
isDefined(chartConfiguration.secondaryAxisOrderBy)
) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.secondaryAxisOrderBy,
graphOrderBy: chartConfiguration.secondaryAxisOrderBy,
groupByField: groupByFieldY,
groupBySubFieldName:
barChartConfiguration.secondaryAxisGroupBySubFieldName,
chartConfiguration.secondaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity:
barChartConfiguration.secondaryAxisGroupByDateGranularity ??
undefined,
chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined,
}),
);
}
@@ -0,0 +1,30 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { GraphOrderBy } from '~/generated/graphql';
const normalizeXValue = (x: number | string | Date): string => {
if (x instanceof Date) {
return x.toISOString();
}
return String(x);
};
export const sortLineChartDataPoints = ({
dataPoints,
orderBy,
}: {
dataPoints: LineChartDataPoint[];
orderBy?: GraphOrderBy | null;
}): LineChartDataPoint[] => {
switch (orderBy) {
case GraphOrderBy.FIELD_ASC:
return [...dataPoints].sort((a, b) =>
normalizeXValue(a.x).localeCompare(normalizeXValue(b.x)),
);
case GraphOrderBy.FIELD_DESC:
return [...dataPoints].sort((a, b) =>
normalizeXValue(b.x).localeCompare(normalizeXValue(a.x)),
);
default:
return dataPoints;
}
};
@@ -0,0 +1,19 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { GraphOrderBy } from '~/generated/graphql';
export const sortLineChartSeries = ({
series,
orderByY,
}: {
series: LineChartSeries[];
orderByY?: GraphOrderBy | null;
}): LineChartSeries[] => {
switch (orderByY) {
case GraphOrderBy.FIELD_ASC:
return [...series].sort((a, b) => b.id.localeCompare(a.id));
case GraphOrderBy.FIELD_DESC:
return [...series].sort((a, b) => a.id.localeCompare(b.id));
default:
return series;
}
};
@@ -0,0 +1,140 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData';
import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData';
import { isDefined } from 'twenty-shared/utils';
import {
AxisNameDisplay,
type LineChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToLineChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
};
type TransformGroupByDataToLineChartDataResult = {
series: LineChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
hasTooManyGroups: boolean;
};
const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = {
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
hasTooManyGroups: false,
};
export const transformGroupByDataToLineChartData = ({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_LINE_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return EMPTY_LINE_CHART_RESULT;
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const queryName = getGroupByQueryName(objectMetadataItem);
const rawResults = groupByData[queryName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return EMPTY_LINE_CHART_RESULT;
}
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
const yAxisLabel = showYAxis
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const showDataLabels = configuration.displayDataLabel ?? false;
const baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToLineChartData({
rawResults: filteredResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
})
: transformOneDimensionalGroupByToLineChartData({
rawResults: filteredResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
});
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
};
};
@@ -0,0 +1,100 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformOneDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
};
export const transformOneDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => {
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const limitedResults = rawResults.slice(
0,
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
);
const unsortedData: LineChartDataPoint[] = limitedResults
.map((result) => {
const dimensionValues = result.groupByDimensionValues;
const rawAggregateValue = result[aggregateOperation];
if (!isDefined(rawAggregateValue)) {
return null;
}
const xValue = isDefined(dimensionValues?.[0])
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
})
: '';
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
return {
x: xValue,
y: aggregateValue,
};
})
.filter((point) => isDefined(point));
const data = sortLineChartDataPoints({
dataPoints: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
});
const series: LineChartSeries[] = [
{
id: aggregateField.name,
label: aggregateField.label,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
data,
},
];
return {
series,
hasTooManyGroups:
rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
};
};
@@ -0,0 +1,130 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints';
import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries';
import { isDefined } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformTwoDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
};
export const transformTwoDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => {
const seriesMap = new Map<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
let hasTooManyGroups = false;
rawResults.forEach((result) => {
const dimensionValues = result.groupByDimensionValues;
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
const rawAggregateValue = result[aggregateOperation];
if (!isDefined(rawAggregateValue)) return;
const xValue = formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
const isNewX = !xValueSet.has(xValue);
if (isNewX && xValueSet.size >= LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS) {
hasTooManyGroups = true;
return;
}
if (isNewX) {
xValueSet.add(xValue);
allXValues.push(xValue);
}
const seriesKey = formatDimensionValue({
value: dimensionValues[1],
fieldMetadata: groupByFieldY,
dateGranularity:
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
});
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
if (!isDefined(aggregateValue)) return;
if (!seriesMap.has(seriesKey)) {
seriesMap.set(seriesKey, new Map());
}
seriesMap.get(seriesKey)!.set(xValue, aggregateValue);
});
const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map(
([seriesKey, xToYMap]) => {
const unsortedData: LineChartDataPoint[] = allXValues.map((xValue) => ({
x: xValue,
y: xToYMap.get(xValue) ?? 0,
}));
const data = sortLineChartDataPoints({
dataPoints: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
});
return {
id: seriesKey,
label: seriesKey,
color: configuration.color as GraphColor,
data,
};
},
);
const series = sortLineChartSeries({
series: unsortedSeries,
orderByY: configuration.secondaryAxisOrderBy,
});
return {
series,
hasTooManyGroups,
};
};