[DASHBOARDS] Move all the graph computing logic to the backend (#17189)
- Create resolvers for each type of charts which needs data transformation after the group by operation: Bar Chart, Line Chart and Pie Chart - Move all the utils to the backend and refactored some into services This allows all the computation to be done in the backend, improving performances in the frontend.
This commit is contained in:
+426
@@ -0,0 +1,426 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum';
|
||||
import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-layout.enum';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant';
|
||||
import { BarChartDataService } from 'src/modules/dashboard/chart-data/services/bar-chart-data.service';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
|
||||
describe('BarChartDataService', () => {
|
||||
let service: BarChartDataService;
|
||||
let mockExecuteGroupByQuery: jest.Mock;
|
||||
let mockGetOrRecomputeManyOrAllFlatEntityMaps: jest.Mock;
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const mockAuthContext: AuthContext = {
|
||||
workspace: { id: workspaceId } as any,
|
||||
};
|
||||
const objectMetadataId = 'test-object-id';
|
||||
|
||||
const mockGroupByField = {
|
||||
id: 'group-by-field-id',
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
};
|
||||
|
||||
const mockAggregateField = {
|
||||
id: 'aggregate-field-id',
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
};
|
||||
|
||||
const mockSelectField = {
|
||||
id: 'select-field-id',
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
options: [
|
||||
{ value: 'open', label: 'Open', color: 'green', position: 0 },
|
||||
{ value: 'closed', label: 'Closed', color: 'red', position: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const mockObjectMetadata = {
|
||||
id: objectMetadataId,
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExecuteGroupByQuery = jest.fn();
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps = jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[mockGroupByField.id]: mockGroupByField,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
[mockSelectField.id]: mockSelectField,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BarChartDataService,
|
||||
{
|
||||
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
useValue: {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps:
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChartDataQueryService,
|
||||
useValue: {
|
||||
executeGroupByQuery: mockExecuteGroupByQuery,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BarChartDataService>(BarChartDataService);
|
||||
});
|
||||
|
||||
describe('getBarChartData - One dimensional', () => {
|
||||
const baseConfiguration = {
|
||||
configurationType: WidgetConfigurationType.BAR_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: mockGroupByField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
layout: BarChartLayout.VERTICAL,
|
||||
};
|
||||
|
||||
it('should transform simple one-dimensional bar chart data', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Inactive'], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({
|
||||
status: 'Active',
|
||||
amount: 10,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
status: 'Inactive',
|
||||
amount: 5,
|
||||
});
|
||||
expect(result.indexBy).toBe('status');
|
||||
expect(result.keys).toEqual(['amount']);
|
||||
expect(result.hasTooManyGroups).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply cumulative transform when isCumulative is true', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Jan'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Feb'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['Mar'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].amount).toBe(10);
|
||||
expect(result.data[1].amount).toBe(30);
|
||||
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([20, 30]);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
it('should handle null values when omitNullValues is true', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [null], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
omitNullValues: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].status).toBe('Active');
|
||||
});
|
||||
|
||||
it('should format null values as "Not Set" when not omitting', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [null], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
omitNullValues: false,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0].status).toBe('Not Set');
|
||||
});
|
||||
|
||||
it('should detect too many groups', async () => {
|
||||
const manyResults = Array.from(
|
||||
{ length: BAR_CHART_MAXIMUM_NUMBER_OF_BARS + 5 },
|
||||
(_, i) => ({
|
||||
groupByDimensionValues: [`Group ${i}`],
|
||||
aggregateValue: i,
|
||||
}),
|
||||
);
|
||||
|
||||
mockExecuteGroupByQuery.mockResolvedValue(manyResults);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.hasTooManyGroups).toBe(true);
|
||||
expect(result.data.length).toBeLessThanOrEqual(
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_BARS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBarChartData - Two dimensional', () => {
|
||||
const twoDimConfiguration = {
|
||||
configurationType: WidgetConfigurationType.BAR_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: mockGroupByField.id,
|
||||
secondaryAxisGroupByFieldMetadataId: mockSelectField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
layout: BarChartLayout.VERTICAL,
|
||||
groupMode: BarChartGroupMode.STACKED,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[mockGroupByField.id]: mockGroupByField,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
[mockSelectField.id]: mockSelectField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform two-dimensional bar chart data', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Jan', 'open'], aggregateValue: 100 },
|
||||
{ groupByDimensionValues: ['Jan', 'closed'], aggregateValue: 50 },
|
||||
{ groupByDimensionValues: ['Feb', 'open'], aggregateValue: 150 },
|
||||
{ groupByDimensionValues: ['Feb', 'closed'], aggregateValue: 75 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.keys).toContain('Open');
|
||||
expect(result.keys).toContain('Closed');
|
||||
expect(result.series).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should apply cumulative transform to two-dimensional data', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Jan', 'open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Jan', 'closed'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Feb', 'open'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['Feb', 'closed'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['Mar', 'open'], aggregateValue: 30 },
|
||||
{ groupByDimensionValues: ['Mar', 'closed'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...twoDimConfiguration,
|
||||
isCumulative: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]['Open']).toBe(10);
|
||||
expect(result.data[0]['Closed']).toBe(10);
|
||||
expect(result.data[1]['Open']).toBe(30);
|
||||
expect(result.data[1]['Closed']).toBe(30);
|
||||
expect(result.data[2]['Open']).toBe(60);
|
||||
expect(result.data[2]['Closed']).toBe(60);
|
||||
});
|
||||
|
||||
it('should filter two-dimensional data by rangeMin when cumulative', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a', 'open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['a', 'closed'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b', 'open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b', 'closed'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['c', 'open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['c', 'closed'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...twoDimConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMin: 50,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data.length).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it('should order keys correctly despite unordered raw results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Oct 16', 'open'], aggregateValue: 100 },
|
||||
{ groupByDimensionValues: ['Oct 17', 'closed'], aggregateValue: 200 },
|
||||
{ groupByDimensionValues: ['Oct 21', 'open'], aggregateValue: 150 },
|
||||
{ groupByDimensionValues: ['Oct 21', 'closed'], aggregateValue: 50 },
|
||||
]);
|
||||
|
||||
const result = await service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.keys).toContain('Open');
|
||||
expect(result.keys).toContain('Closed');
|
||||
expect(result.data).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw when object metadata is not found', async () => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: { byId: {} },
|
||||
flatFieldMetadataMaps: { byId: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId: 'non-existent-id',
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.BAR_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: mockGroupByField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when field metadata is not found', async () => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: { byId: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.BAR_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: 'non-existent-field',
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from 'src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
import { LineChartDataService } from 'src/modules/dashboard/chart-data/services/line-chart-data.service';
|
||||
|
||||
describe('LineChartDataService', () => {
|
||||
let service: LineChartDataService;
|
||||
let mockExecuteGroupByQuery: jest.Mock;
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const mockAuthContext: AuthContext = {
|
||||
workspace: { id: workspaceId } as any,
|
||||
};
|
||||
const objectMetadataId = 'test-object-id';
|
||||
|
||||
const mockGroupByFieldX = {
|
||||
id: 'group-by-field-id',
|
||||
name: 'createdAt',
|
||||
label: 'Created At',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
};
|
||||
|
||||
const mockGroupByFieldXText = {
|
||||
id: 'group-by-field-text-id',
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: FieldMetadataType.TEXT,
|
||||
};
|
||||
|
||||
const mockGroupByFieldY = {
|
||||
id: 'secondary-field-id',
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: FieldMetadataType.TEXT,
|
||||
};
|
||||
|
||||
const mockAggregateField = {
|
||||
id: 'aggregate-field-id',
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
};
|
||||
|
||||
const mockObjectMetadata = {
|
||||
id: objectMetadataId,
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExecuteGroupByQuery = jest.fn();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LineChartDataService,
|
||||
{
|
||||
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
useValue: {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[mockGroupByFieldX.id]: mockGroupByFieldX,
|
||||
[mockGroupByFieldXText.id]: mockGroupByFieldXText,
|
||||
[mockGroupByFieldY.id]: mockGroupByFieldY,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChartDataQueryService,
|
||||
useValue: {
|
||||
executeGroupByQuery: mockExecuteGroupByQuery,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LineChartDataService>(LineChartDataService);
|
||||
});
|
||||
|
||||
describe('getLineChartData - One dimensional', () => {
|
||||
const baseConfiguration = {
|
||||
configurationType: WidgetConfigurationType.LINE_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: mockGroupByFieldXText.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
};
|
||||
|
||||
it('should transform simple one-dimensional line chart data', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Qualification'], aggregateValue: 150000 },
|
||||
{ groupByDimensionValues: ['Proposal'], aggregateValue: 280000 },
|
||||
{ groupByDimensionValues: ['Closed Won'], aggregateValue: 450000 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
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 apply cumulative transform when isCumulative is true', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Jan'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Feb'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['Mar'], aggregateValue: 30 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series[0].data).toEqual([
|
||||
{ x: 'Jan', y: 10 },
|
||||
{ x: 'Feb', y: 30 },
|
||||
{ x: 'Mar', y: 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.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
rangeMin: 15,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series[0].data.map((d) => d.y)).toEqual([20, 30]);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
it('should handle null y values by keeping running total', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['a'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['b'], aggregateValue: 0 },
|
||||
{ groupByDimensionValues: ['c'], aggregateValue: 20 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
isCumulative: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series[0].data).toEqual([
|
||||
{ x: 'a', y: 10 },
|
||||
{ x: 'b', y: 10 },
|
||||
{ x: 'c', y: 30 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
expect(result.series[0].data).toEqual([]);
|
||||
expect(result.hasTooManyGroups).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect too many data points', async () => {
|
||||
const manyResults = Array.from(
|
||||
{ length: LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS + 5 },
|
||||
(_, i) => ({
|
||||
groupByDimensionValues: [`Point ${i}`],
|
||||
aggregateValue: i * 100,
|
||||
}),
|
||||
);
|
||||
|
||||
mockExecuteGroupByQuery.mockResolvedValue(manyResults);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.hasTooManyGroups).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLineChartData - Two dimensional', () => {
|
||||
const twoDimConfiguration = {
|
||||
configurationType: WidgetConfigurationType.LINE_CHART,
|
||||
primaryAxisGroupByFieldMetadataId: mockGroupByFieldX.id,
|
||||
secondaryAxisGroupByFieldMetadataId: mockGroupByFieldY.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
};
|
||||
|
||||
it('should create multiple series from 2D groupBy results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01', 'Qualification'],
|
||||
aggregateValue: 50000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01', 'Proposal'],
|
||||
aggregateValue: 75000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02-01', 'Qualification'],
|
||||
aggregateValue: 60000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02-01', 'Proposal'],
|
||||
aggregateValue: 90000,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(2);
|
||||
expect(result.series.every((s) => s.data.length === 2)).toBe(true);
|
||||
expect(result.hasTooManyGroups).toBe(false);
|
||||
});
|
||||
|
||||
it('should normalize sparse data (fill missing x values with 0)', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage A'],
|
||||
aggregateValue: 100,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
aggregateValue: 200,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage B'],
|
||||
aggregateValue: 150,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03-01', 'Stage B'],
|
||||
aggregateValue: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should apply cumulative transform to each series independently', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['2024-02-01', 'A'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['2024-01-01', 'B'], aggregateValue: 100 },
|
||||
{ groupByDimensionValues: ['2024-02-01', 'B'], aggregateValue: 200 },
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...twoDimConfiguration,
|
||||
isCumulative: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
const seriesA = result.series.find((s) => s.id === 'A');
|
||||
const seriesB = result.series.find((s) => s.id === 'B');
|
||||
|
||||
expect(seriesA?.data[0].y).toBe(10);
|
||||
expect(seriesA?.data[1].y).toBe(30);
|
||||
expect(seriesB?.data[0].y).toBe(100);
|
||||
expect(seriesB?.data[1].y).toBe(300);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series).toEqual([]);
|
||||
expect(result.hasTooManyGroups).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip results with missing dimension values', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 100 },
|
||||
{
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
aggregateValue: 200,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: twoDimConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
expect(result.series[0].data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from 'src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/pie-chart-data.service';
|
||||
|
||||
describe('PieChartDataService', () => {
|
||||
let service: PieChartDataService;
|
||||
let mockExecuteGroupByQuery: jest.Mock;
|
||||
let mockGetOrRecomputeManyOrAllFlatEntityMaps: jest.Mock;
|
||||
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const mockAuthContext: AuthContext = {
|
||||
workspace: { id: workspaceId } as any,
|
||||
};
|
||||
const objectMetadataId = 'test-object-id';
|
||||
|
||||
const mockGroupByField = {
|
||||
id: 'group-by-field-id',
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
};
|
||||
|
||||
const mockSelectField = {
|
||||
id: 'select-field-id',
|
||||
name: 'stage',
|
||||
label: 'Stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
options: [
|
||||
{ value: 'open', label: 'Open', color: 'green', position: 0 },
|
||||
{ value: 'closed', label: 'Closed', color: 'red', position: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const mockAggregateField = {
|
||||
id: 'aggregate-field-id',
|
||||
name: 'id',
|
||||
label: 'Id',
|
||||
type: FieldMetadataType.UUID,
|
||||
};
|
||||
|
||||
const mockObjectMetadata = {
|
||||
id: objectMetadataId,
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExecuteGroupByQuery = jest.fn();
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps = jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[mockGroupByField.id]: mockGroupByField,
|
||||
[mockSelectField.id]: mockSelectField,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PieChartDataService,
|
||||
{
|
||||
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
useValue: {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps:
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ChartDataQueryService,
|
||||
useValue: {
|
||||
executeGroupByQuery: mockExecuteGroupByQuery,
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PieChartDataService>(PieChartDataService);
|
||||
});
|
||||
|
||||
describe('getPieChartData', () => {
|
||||
const baseConfiguration = {
|
||||
configurationType: WidgetConfigurationType.PIE_CHART,
|
||||
groupByFieldMetadataId: mockGroupByField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
displayLegend: true,
|
||||
};
|
||||
|
||||
it('should transform simple pie chart data', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['Inactive'], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]).toEqual({
|
||||
id: 'Active',
|
||||
value: 10,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
id: 'Inactive',
|
||||
value: 5,
|
||||
});
|
||||
expect(result.showLegend).toBe(true);
|
||||
expect(result.hasTooManyGroups).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep null group buckets aligned with their aggregate values', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [null], aggregateValue: 2 },
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([
|
||||
{ id: 'Not Set', value: 2 },
|
||||
{ id: 'Active', value: 5 },
|
||||
]);
|
||||
expect(result.formattedToRawLookup?.['Not Set']).toBeUndefined();
|
||||
expect(result.formattedToRawLookup?.['Active']).toBe('Active');
|
||||
});
|
||||
|
||||
it('should hide empty category when hideEmptyCategory is true', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [null], aggregateValue: 2 },
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
hideEmptyCategory: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe('Active');
|
||||
});
|
||||
|
||||
it('should flag too many groups and limit slices', async () => {
|
||||
const manyResults = Array.from(
|
||||
{ length: PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + 5 },
|
||||
(_, index) => ({
|
||||
groupByDimensionValues: [`Group ${index}`],
|
||||
aggregateValue: index + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
mockExecuteGroupByQuery.mockResolvedValue(manyResults);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: baseConfiguration as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.hasTooManyGroups).toBe(true);
|
||||
expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES);
|
||||
});
|
||||
|
||||
it('should respect displayLegend configuration', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
displayLegend: false,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.showLegend).toBe(false);
|
||||
});
|
||||
|
||||
it('should respect displayDataLabel configuration', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
displayDataLabel: true,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.showDataLabels).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect showCenterMetric configuration', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
showCenterMetric: false,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.showCenterMetric).toBe(false);
|
||||
});
|
||||
|
||||
it('should format select field values using option labels', async () => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[mockSelectField.id]: mockSelectField,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: ['open'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['closed'], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
...baseConfiguration,
|
||||
groupByFieldMetadataId: mockSelectField.id,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data[0].id).toBe('Open');
|
||||
expect(result.data[1].id).toBe('Closed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw when configuration type is not PIE_CHART', async () => {
|
||||
await expect(
|
||||
service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.BAR_CHART,
|
||||
groupByFieldMetadataId: mockGroupByField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when object metadata is not found', async () => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: { byId: {} },
|
||||
flatFieldMetadataMaps: { byId: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId: 'non-existent-id',
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.PIE_CHART,
|
||||
groupByFieldMetadataId: mockGroupByField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when field metadata is not found', async () => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: { byId: {} },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.PIE_CHART,
|
||||
groupByFieldMetadataId: 'non-existent-field',
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean field formatting', () => {
|
||||
const booleanField = {
|
||||
id: 'boolean-field-id',
|
||||
name: 'isActive',
|
||||
label: 'Is Active',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue({
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { [objectMetadataId]: mockObjectMetadata },
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: {
|
||||
[booleanField.id]: booleanField,
|
||||
[mockAggregateField.id]: mockAggregateField,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should format boolean values as Yes/No', async () => {
|
||||
mockExecuteGroupByQuery.mockResolvedValue([
|
||||
{ groupByDimensionValues: [true], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: [false], aggregateValue: 5 },
|
||||
]);
|
||||
|
||||
const result = await service.getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration: {
|
||||
configurationType: WidgetConfigurationType.PIE_CHART,
|
||||
groupByFieldMetadataId: booleanField.id,
|
||||
aggregateFieldMetadataId: mockAggregateField.id,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
} as any,
|
||||
authContext: mockAuthContext,
|
||||
});
|
||||
|
||||
expect(result.data[0].id).toBe('Yes');
|
||||
expect(result.data[1].id).toBe('No');
|
||||
});
|
||||
});
|
||||
});
|
||||
+708
@@ -0,0 +1,708 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
|
||||
import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum';
|
||||
import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-layout.enum';
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant';
|
||||
import { BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-groups-per-bar.constant';
|
||||
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant';
|
||||
import { BarChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/bar-chart-data-output.dto';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
generateChartDataExceptionMessage,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
|
||||
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';
|
||||
import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util';
|
||||
import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
|
||||
|
||||
type GetBarChartDataParams = {
|
||||
workspaceId: string;
|
||||
objectMetadataId: string;
|
||||
configuration: BarChartConfigurationDTO;
|
||||
authContext: AuthContext;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BarChartDataService {
|
||||
constructor(
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly chartDataQueryService: ChartDataQueryService,
|
||||
) {}
|
||||
|
||||
async getBarChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration,
|
||||
authContext,
|
||||
}: GetBarChartDataParams): Promise<BarChartDataOutputDTO> {
|
||||
try {
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
'Widget has no objectMetadataId',
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const flatObjectMetadata = flatObjectMetadataMaps.byId[objectMetadataId];
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
objectMetadataId,
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const primaryAxisGroupByField = getFieldMetadata(
|
||||
configuration.primaryAxisGroupByFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const aggregateField = getFieldMetadata(
|
||||
configuration.aggregateFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const isTwoDimensional = isDefined(
|
||||
configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
);
|
||||
|
||||
let secondaryAxisGroupByField: FlatFieldMetadata | undefined;
|
||||
|
||||
if (isTwoDimensional) {
|
||||
secondaryAxisGroupByField = getFieldMetadata(
|
||||
configuration.secondaryAxisGroupByFieldMetadataId!,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
}
|
||||
|
||||
const isStackedTwoDimensional =
|
||||
isTwoDimensional &&
|
||||
configuration.groupMode === BarChartGroupMode.STACKED;
|
||||
|
||||
const limit = isStackedTwoDimensional
|
||||
? BAR_CHART_MAXIMUM_NUMBER_OF_BARS *
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR +
|
||||
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
|
||||
: BAR_CHART_MAXIMUM_NUMBER_OF_BARS +
|
||||
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS;
|
||||
|
||||
const userTimezone = configuration.timezone ?? 'UTC';
|
||||
const firstDayOfTheWeek: CalendarStartDay =
|
||||
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
|
||||
CalendarStartDay.MONDAY;
|
||||
|
||||
const objectIdByNameSingular: Record<string, string> = {};
|
||||
|
||||
for (const objectId in flatObjectMetadataMaps.byId) {
|
||||
const objMetadata = flatObjectMetadataMaps.byId[objectId];
|
||||
|
||||
if (isDefined(objMetadata)) {
|
||||
objectIdByNameSingular[objMetadata.nameSingular] = objectId;
|
||||
}
|
||||
}
|
||||
|
||||
const rawResults = await this.chartDataQueryService.executeGroupByQuery({
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
authContext,
|
||||
groupByFieldMetadataId: configuration.primaryAxisGroupByFieldMetadataId,
|
||||
groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
|
||||
aggregateOperation: configuration.aggregateOperation,
|
||||
filter: configuration.filter,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
limit,
|
||||
primaryAxisOrderBy: configuration.primaryAxisOrderBy,
|
||||
secondaryGroupByFieldMetadataId:
|
||||
configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
secondaryGroupBySubFieldName:
|
||||
configuration.secondaryAxisGroupBySubFieldName,
|
||||
secondaryDateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity,
|
||||
secondaryAxisOrderBy: configuration.secondaryAxisOrderBy,
|
||||
});
|
||||
|
||||
if (isTwoDimensional && isDefined(secondaryAxisGroupByField)) {
|
||||
return this.transformToTwoDimensionalBarChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
}
|
||||
|
||||
return this.transformToOneDimensionalBarChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Bar chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private transformToOneDimensionalBarChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
aggregateField: FlatFieldMetadata;
|
||||
configuration: BarChartConfigurationDTO;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
}): BarChartDataOutputDTO {
|
||||
const layout = configuration.layout ?? BarChartLayout.VERTICAL;
|
||||
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
|
||||
|
||||
const filteredResults = configuration.omitNullValues
|
||||
? rawResults.filter(
|
||||
(result) =>
|
||||
isDefined(result.groupByDimensionValues?.[0]) &&
|
||||
result.aggregateValue !== 0,
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!configuration.isCumulative &&
|
||||
(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,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
isDescOrder,
|
||||
isTwoDimensional: false,
|
||||
});
|
||||
|
||||
const selectOptions = getSelectOptions(primaryAxisGroupByField);
|
||||
|
||||
const convertedFirstDayOfTheWeek =
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
const indexByKey = configuration.primaryAxisGroupBySubFieldName
|
||||
? `${primaryAxisGroupByField.name}${this.capitalizeFirst(configuration.primaryAxisGroupBySubFieldName)}`
|
||||
: primaryAxisGroupByField.name;
|
||||
|
||||
const aggregateValueKey =
|
||||
indexByKey === aggregateField.name
|
||||
? `${aggregateField.name}-aggregate`
|
||||
: aggregateField.name;
|
||||
|
||||
const { processedDataPoints, formattedToRawLookup } =
|
||||
processOneDimensionalResults({
|
||||
rawResults: gapFilledResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const sortedData = sortChartDataIfNeeded({
|
||||
data: processedDataPoints,
|
||||
orderBy: configuration.primaryAxisOrderBy,
|
||||
manualSortOrder: configuration.primaryAxisManualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue: (item) => item.formattedValue,
|
||||
getNumericValue: (item) => item.aggregateValue,
|
||||
selectFieldOptions: selectOptions,
|
||||
fieldType: primaryAxisGroupByField.type,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
});
|
||||
|
||||
const limitedSortedData = sortedData.slice(
|
||||
0,
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_BARS,
|
||||
);
|
||||
|
||||
const transformedData = configuration.isCumulative
|
||||
? this.applyCumulativeTransformInternal(
|
||||
limitedSortedData,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: limitedSortedData;
|
||||
|
||||
const data = transformedData.map((item) => ({
|
||||
[indexByKey]: item.formattedValue,
|
||||
[aggregateValueKey]: item.aggregateValue,
|
||||
}));
|
||||
|
||||
const series = [
|
||||
{
|
||||
key: aggregateValueKey,
|
||||
label: aggregateField.label,
|
||||
},
|
||||
];
|
||||
|
||||
const categoryLabel = primaryAxisGroupByField.label;
|
||||
const valueLabel = `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`;
|
||||
|
||||
const xAxisLabel = isHorizontal ? valueLabel : categoryLabel;
|
||||
const yAxisLabel = isHorizontal ? categoryLabel : valueLabel;
|
||||
|
||||
return {
|
||||
data,
|
||||
indexBy: indexByKey,
|
||||
keys: [aggregateValueKey],
|
||||
series,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showLegend: configuration.displayLegend ?? true,
|
||||
showDataLabels: configuration.displayDataLabel ?? false,
|
||||
layout,
|
||||
groupMode: configuration.groupMode ?? BarChartGroupMode.GROUPED,
|
||||
hasTooManyGroups:
|
||||
filteredResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS ||
|
||||
dateRangeWasTruncated,
|
||||
formattedToRawLookup: Object.fromEntries(formattedToRawLookup),
|
||||
};
|
||||
}
|
||||
|
||||
private transformToTwoDimensionalBarChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
secondaryAxisGroupByField: FlatFieldMetadata;
|
||||
aggregateField: FlatFieldMetadata;
|
||||
configuration: BarChartConfigurationDTO;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
}): BarChartDataOutputDTO {
|
||||
const layout = configuration.layout ?? BarChartLayout.VERTICAL;
|
||||
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
|
||||
|
||||
const filteredResults = configuration.omitNullValues
|
||||
? rawResults.filter(
|
||||
(result) =>
|
||||
isDefined(result.groupByDimensionValues?.[0]) &&
|
||||
result.aggregateValue !== 0,
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const effectiveGroupMode =
|
||||
configuration.groupMode ?? BarChartGroupMode.STACKED;
|
||||
const isStacked = effectiveGroupMode === BarChartGroupMode.STACKED;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!configuration.isCumulative &&
|
||||
!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,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
isDescOrder,
|
||||
isTwoDimensional: true,
|
||||
});
|
||||
|
||||
const primarySelectOptions = getSelectOptions(primaryAxisGroupByField);
|
||||
const secondarySelectOptions = getSelectOptions(secondaryAxisGroupByField);
|
||||
|
||||
const indexByKey = configuration.primaryAxisGroupBySubFieldName
|
||||
? `${primaryAxisGroupByField.name}${this.capitalizeFirst(configuration.primaryAxisGroupBySubFieldName)}`
|
||||
: primaryAxisGroupByField.name;
|
||||
|
||||
const convertedFirstDayOfTheWeek =
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
const {
|
||||
processedDataPoints,
|
||||
formattedToRawLookup,
|
||||
secondaryFormattedToRawLookup,
|
||||
} = processTwoDimensionalResults({
|
||||
rawResults: gapFilledResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
primaryDateGranularity: configuration.primaryAxisDateGranularity,
|
||||
primarySubFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
secondaryDateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity,
|
||||
secondarySubFieldName: configuration.secondaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const allSecondaryValues = new Set<string>();
|
||||
|
||||
for (const point of processedDataPoints) {
|
||||
allSecondaryValues.add(point.yFormatted);
|
||||
}
|
||||
|
||||
const dataMap = new Map<string, Record<string, string | number>>();
|
||||
|
||||
for (const point of processedDataPoints) {
|
||||
if (!dataMap.has(point.xFormatted)) {
|
||||
dataMap.set(point.xFormatted, {
|
||||
[indexByKey]: point.xFormatted,
|
||||
});
|
||||
}
|
||||
|
||||
const datum = dataMap.get(point.xFormatted)!;
|
||||
|
||||
datum[point.yFormatted] = point.aggregateValue;
|
||||
}
|
||||
|
||||
let unsortedData = Array.from(dataMap.values());
|
||||
|
||||
const sortedData = sortChartDataIfNeeded({
|
||||
data: unsortedData,
|
||||
orderBy: configuration.primaryAxisOrderBy,
|
||||
manualSortOrder: configuration.primaryAxisManualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue: (item) => String(item[indexByKey]),
|
||||
getNumericValue: (item) => {
|
||||
let sum = 0;
|
||||
|
||||
for (const key of allSecondaryValues) {
|
||||
const value = item[key];
|
||||
|
||||
if (isNumber(value)) {
|
||||
sum += value;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
},
|
||||
selectFieldOptions: primarySelectOptions,
|
||||
fieldType: primaryAxisGroupByField.type,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
});
|
||||
|
||||
const limitedData = sortedData.slice(0, BAR_CHART_MAXIMUM_NUMBER_OF_BARS);
|
||||
|
||||
const keys = Array.from(allSecondaryValues);
|
||||
|
||||
const sortedKeys = this.sortSecondaryAxisKeys({
|
||||
keys,
|
||||
data: limitedData,
|
||||
configuration,
|
||||
secondaryFormattedToRawLookup,
|
||||
secondarySelectOptions,
|
||||
secondaryAxisGroupByField,
|
||||
});
|
||||
|
||||
const hasTooManyBars = sortedData.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS;
|
||||
const hasTooManyGroupsPerBar =
|
||||
keys.length > BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR;
|
||||
|
||||
let finalLimitedData = limitedData;
|
||||
const limitedKeys = sortedKeys.slice(
|
||||
0,
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR,
|
||||
);
|
||||
|
||||
if (!isStacked) {
|
||||
const totalSegments = finalLimitedData.length * limitedKeys.length;
|
||||
const hasTooManySegments =
|
||||
totalSegments > BAR_CHART_MAXIMUM_NUMBER_OF_BARS;
|
||||
|
||||
if (hasTooManySegments) {
|
||||
const maxXValues = Math.floor(
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_BARS / limitedKeys.length,
|
||||
);
|
||||
|
||||
finalLimitedData = finalLimitedData.slice(0, Math.max(1, maxXValues));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!configuration.isCumulative &&
|
||||
isStacked &&
|
||||
(isDefined(configuration.rangeMin) || isDefined(configuration.rangeMax))
|
||||
) {
|
||||
finalLimitedData = filterTwoDimensionalDataByRange(
|
||||
finalLimitedData,
|
||||
limitedKeys,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
);
|
||||
}
|
||||
|
||||
const finalData = configuration.isCumulative
|
||||
? this.applyCumulativeTwoDimensional(
|
||||
finalLimitedData,
|
||||
limitedKeys,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: finalLimitedData;
|
||||
|
||||
const series = limitedKeys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
}));
|
||||
|
||||
const categoryLabel = primaryAxisGroupByField.label;
|
||||
const valueLabel = `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`;
|
||||
|
||||
const xAxisLabel = isHorizontal ? valueLabel : categoryLabel;
|
||||
const yAxisLabel = isHorizontal ? categoryLabel : valueLabel;
|
||||
|
||||
let hasTooManyGroups = hasTooManyBars || hasTooManyGroupsPerBar;
|
||||
|
||||
if (!isStacked) {
|
||||
const totalSegments = limitedData.length * limitedKeys.length;
|
||||
const hasTooManySegments =
|
||||
totalSegments > BAR_CHART_MAXIMUM_NUMBER_OF_BARS;
|
||||
|
||||
hasTooManyGroups = hasTooManyGroups || hasTooManySegments;
|
||||
}
|
||||
|
||||
hasTooManyGroups = hasTooManyGroups || dateRangeWasTruncated;
|
||||
|
||||
const mergedLookup = new Map([
|
||||
...formattedToRawLookup,
|
||||
...secondaryFormattedToRawLookup,
|
||||
]);
|
||||
|
||||
return {
|
||||
data: finalData,
|
||||
indexBy: indexByKey,
|
||||
keys: limitedKeys,
|
||||
series,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showLegend: configuration.displayLegend ?? true,
|
||||
showDataLabels: configuration.displayDataLabel ?? false,
|
||||
layout,
|
||||
groupMode: configuration.groupMode ?? BarChartGroupMode.GROUPED,
|
||||
hasTooManyGroups,
|
||||
formattedToRawLookup: Object.fromEntries(mergedLookup),
|
||||
};
|
||||
}
|
||||
|
||||
private sortSecondaryAxisKeys({
|
||||
keys,
|
||||
data,
|
||||
configuration,
|
||||
secondaryFormattedToRawLookup,
|
||||
secondarySelectOptions,
|
||||
secondaryAxisGroupByField,
|
||||
}: {
|
||||
keys: string[];
|
||||
data: Record<string, string | number>[];
|
||||
configuration: BarChartConfigurationDTO;
|
||||
secondaryFormattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
secondarySelectOptions: FieldMetadataOption[] | null;
|
||||
secondaryAxisGroupByField: FlatFieldMetadata;
|
||||
}): string[] {
|
||||
const orderBy = configuration.secondaryAxisOrderBy;
|
||||
|
||||
if (!isDefined(orderBy)) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
return sortSecondaryAxisData({
|
||||
items: keys,
|
||||
orderBy,
|
||||
manualSortOrder: configuration.secondaryAxisManualSortOrder,
|
||||
formattedToRawLookup: secondaryFormattedToRawLookup,
|
||||
getFormattedValue: (key) => key,
|
||||
getNumericValue: (key) => {
|
||||
let sum = 0;
|
||||
|
||||
for (const datum of data) {
|
||||
const value = datum[key];
|
||||
|
||||
if (isNumber(value)) {
|
||||
sum += value;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
},
|
||||
selectFieldOptions: secondarySelectOptions,
|
||||
fieldType: secondaryAxisGroupByField.type,
|
||||
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.secondaryAxisGroupByDateGranularity,
|
||||
});
|
||||
}
|
||||
|
||||
private applyCumulativeTwoDimensional(
|
||||
data: Record<string, string | number>[],
|
||||
keys: string[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): Record<string, string | number>[] {
|
||||
const runningTotals: Record<string, number> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
runningTotals[key] = 0;
|
||||
}
|
||||
|
||||
const result: Record<string, string | number>[] = [];
|
||||
|
||||
for (const datum of data) {
|
||||
const newDatum = { ...datum };
|
||||
|
||||
for (const key of keys) {
|
||||
const value = datum[key];
|
||||
|
||||
if (isNumber(value)) {
|
||||
runningTotals[key] += value;
|
||||
}
|
||||
|
||||
newDatum[key] = runningTotals[key];
|
||||
}
|
||||
|
||||
const totalValue = keys.reduce((sum, key) => {
|
||||
const value = newDatum[key];
|
||||
|
||||
return sum + (isNumber(value) ? value : 0);
|
||||
}, 0);
|
||||
|
||||
const isOutOfRange =
|
||||
(isDefined(rangeMin) && totalValue < rangeMin) ||
|
||||
(isDefined(rangeMax) && totalValue > rangeMax);
|
||||
|
||||
if (!isOutOfRange) {
|
||||
result.push(newDatum);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyCumulativeTransformInternal(
|
||||
data: Array<{
|
||||
formattedValue: string;
|
||||
aggregateValue: number;
|
||||
rawValue: RawDimensionValue;
|
||||
}>,
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): Array<{
|
||||
formattedValue: string;
|
||||
aggregateValue: number;
|
||||
rawValue: RawDimensionValue;
|
||||
}> {
|
||||
const result: Array<{
|
||||
formattedValue: string;
|
||||
aggregateValue: number;
|
||||
rawValue: RawDimensionValue;
|
||||
}> = [];
|
||||
let runningTotal = 0;
|
||||
|
||||
for (const point of data) {
|
||||
runningTotal += point.aggregateValue;
|
||||
|
||||
const cumulativeValue = runningTotal;
|
||||
|
||||
const isOutOfRange =
|
||||
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
|
||||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
|
||||
|
||||
if (!isOutOfRange) {
|
||||
result.push({ ...point, aggregateValue: cumulativeValue });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import {
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByWithGroupBy,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
|
||||
import { ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
|
||||
import { GRAPH_DEFAULT_ORDER_BY } from 'src/modules/dashboard/chart-data/constants/graph-default-order-by.constant';
|
||||
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { buildAggregateFieldKey } from 'src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util';
|
||||
import {
|
||||
buildGroupByFieldObject,
|
||||
type GroupByFieldObject,
|
||||
} from 'src/modules/dashboard/chart-data/utils/build-group-by-field-object.util';
|
||||
import { convertChartFilterToGqlOperationFilter } from 'src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util';
|
||||
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
|
||||
import { getGroupByOrderBy } from 'src/modules/dashboard/chart-data/utils/get-group-by-order-by.util';
|
||||
import { isRelationNestedFieldDateKind } from 'src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util';
|
||||
import { transformAggregateValue } from 'src/modules/dashboard/chart-data/utils/transform-aggregate-value.util';
|
||||
|
||||
type ExecuteGroupByQueryParams = {
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
objectIdByNameSingular: Record<string, string>;
|
||||
authContext: AuthContext;
|
||||
groupByFieldMetadataId: string;
|
||||
groupBySubFieldName?: string | null;
|
||||
aggregateFieldMetadataId: string;
|
||||
aggregateOperation: AggregateOperations;
|
||||
filter?: ChartFilter;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
limit: number;
|
||||
secondaryGroupByFieldMetadataId?: string;
|
||||
secondaryGroupBySubFieldName?: string | null;
|
||||
secondaryDateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
primaryAxisOrderBy?: GraphOrderBy;
|
||||
secondaryAxisOrderBy?: GraphOrderBy;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ChartDataQueryService {
|
||||
constructor(
|
||||
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
|
||||
) {}
|
||||
|
||||
async executeGroupByQuery({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
authContext,
|
||||
groupByFieldMetadataId,
|
||||
groupBySubFieldName,
|
||||
aggregateFieldMetadataId,
|
||||
aggregateOperation,
|
||||
filter,
|
||||
dateGranularity,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
limit,
|
||||
primaryAxisOrderBy,
|
||||
secondaryGroupByFieldMetadataId,
|
||||
secondaryGroupBySubFieldName,
|
||||
secondaryDateGranularity,
|
||||
secondaryAxisOrderBy,
|
||||
}: ExecuteGroupByQueryParams): Promise<GroupByRawResult[]> {
|
||||
const gqlOperationFilter = convertChartFilterToGqlOperationFilter({
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
userTimezone,
|
||||
});
|
||||
|
||||
const primaryGroupByField = getFieldMetadata(
|
||||
groupByFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const aggregateField = getFieldMetadata(
|
||||
aggregateFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const isPrimaryFieldDate = isFieldMetadataDateKind(
|
||||
primaryGroupByField.type,
|
||||
);
|
||||
|
||||
const isPrimaryNestedDate = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: primaryGroupByField,
|
||||
relationNestedFieldName: groupBySubFieldName ?? undefined,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const shouldApplyPrimaryDateGranularity =
|
||||
isPrimaryFieldDate || isPrimaryNestedDate;
|
||||
|
||||
const groupBy: GroupByFieldObject[] = [];
|
||||
|
||||
groupBy.push(
|
||||
buildGroupByFieldObject({
|
||||
fieldMetadata: primaryGroupByField,
|
||||
subFieldName: groupBySubFieldName,
|
||||
dateGranularity: shouldApplyPrimaryDateGranularity
|
||||
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
|
||||
: undefined,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField: isPrimaryNestedDate,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
const orderBy: OrderByWithGroupBy = [];
|
||||
|
||||
const primaryOrderBy = getGroupByOrderBy({
|
||||
graphOrderBy: primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
|
||||
groupByFieldMetadata: primaryGroupByField,
|
||||
groupBySubFieldName,
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata: aggregateField,
|
||||
dateGranularity: shouldApplyPrimaryDateGranularity
|
||||
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (isDefined(primaryOrderBy)) {
|
||||
orderBy.push(primaryOrderBy);
|
||||
}
|
||||
|
||||
if (isDefined(secondaryGroupByFieldMetadataId)) {
|
||||
const secondaryGroupByField = getFieldMetadata(
|
||||
secondaryGroupByFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const isSecondaryFieldDate = isFieldMetadataDateKind(
|
||||
secondaryGroupByField.type,
|
||||
);
|
||||
const isSecondaryNestedDate = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: secondaryGroupByField,
|
||||
relationNestedFieldName: secondaryGroupBySubFieldName ?? undefined,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const shouldApplySecondaryDateGranularity =
|
||||
isSecondaryFieldDate || isSecondaryNestedDate;
|
||||
|
||||
groupBy.push(
|
||||
buildGroupByFieldObject({
|
||||
fieldMetadata: secondaryGroupByField,
|
||||
subFieldName: secondaryGroupBySubFieldName,
|
||||
dateGranularity: shouldApplySecondaryDateGranularity
|
||||
? (secondaryDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
|
||||
: undefined,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField: isSecondaryNestedDate,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDefined(secondaryAxisOrderBy)) {
|
||||
const secondaryOrderByItem = getGroupByOrderBy({
|
||||
graphOrderBy: secondaryAxisOrderBy,
|
||||
groupByFieldMetadata: secondaryGroupByField,
|
||||
groupBySubFieldName: secondaryGroupBySubFieldName,
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata: aggregateField,
|
||||
dateGranularity: shouldApplySecondaryDateGranularity
|
||||
? (secondaryDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (isDefined(secondaryOrderByItem)) {
|
||||
orderBy.push(secondaryOrderByItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateFieldKey = buildAggregateFieldKey({
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata: aggregateField,
|
||||
});
|
||||
|
||||
const selectedFields = {
|
||||
[aggregateFieldKey]: true,
|
||||
groupByDimensionValues: true,
|
||||
};
|
||||
|
||||
const results = await this.commonGroupByQueryRunnerService.execute(
|
||||
{
|
||||
filter: gqlOperationFilter,
|
||||
orderBy: orderBy.length > 0 ? orderBy : undefined,
|
||||
groupBy: groupBy as ObjectRecordGroupBy,
|
||||
selectedFields,
|
||||
limit,
|
||||
},
|
||||
{
|
||||
authContext,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
},
|
||||
);
|
||||
|
||||
return results.map((result) => ({
|
||||
groupByDimensionValues: result.groupByDimensionValues ?? [],
|
||||
aggregateValue: transformAggregateValue({
|
||||
rawValue: result[aggregateFieldKey],
|
||||
aggregateFieldType: aggregateField.type,
|
||||
aggregateOperation,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant';
|
||||
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from 'src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant';
|
||||
import { LINE_CHART_MAXIMUM_NUMBER_OF_NON_STACKED_SERIES } from 'src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-non-stacked-series.constant';
|
||||
import { LINE_CHART_MAXIMUM_NUMBER_OF_STACKED_SERIES } from 'src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-stacked-series.constant';
|
||||
import { LineChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-output.dto';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
generateChartDataExceptionMessage,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
|
||||
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';
|
||||
import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util';
|
||||
import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
|
||||
|
||||
type GetLineChartDataParams = {
|
||||
workspaceId: string;
|
||||
objectMetadataId: string;
|
||||
configuration: LineChartConfigurationDTO;
|
||||
authContext: AuthContext;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LineChartDataService {
|
||||
constructor(
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly chartDataQueryService: ChartDataQueryService,
|
||||
) {}
|
||||
|
||||
async getLineChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration,
|
||||
authContext,
|
||||
}: GetLineChartDataParams): Promise<LineChartDataOutputDTO> {
|
||||
try {
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
'Widget has no objectMetadataId',
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const flatObjectMetadata = flatObjectMetadataMaps.byId[objectMetadataId];
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
objectMetadataId,
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const primaryAxisGroupByField = getFieldMetadata(
|
||||
configuration.primaryAxisGroupByFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const aggregateField = getFieldMetadata(
|
||||
configuration.aggregateFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const isTwoDimensional = isDefined(
|
||||
configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
);
|
||||
|
||||
let secondaryAxisGroupByField: FlatFieldMetadata | undefined;
|
||||
|
||||
if (isTwoDimensional) {
|
||||
secondaryAxisGroupByField = getFieldMetadata(
|
||||
configuration.secondaryAxisGroupByFieldMetadataId!,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
}
|
||||
|
||||
const isStackedTwoDimensional =
|
||||
isTwoDimensional && configuration.isStacked === true;
|
||||
|
||||
const maxSeriesForQuery = isStackedTwoDimensional
|
||||
? LINE_CHART_MAXIMUM_NUMBER_OF_STACKED_SERIES
|
||||
: LINE_CHART_MAXIMUM_NUMBER_OF_NON_STACKED_SERIES;
|
||||
|
||||
const limit = isTwoDimensional
|
||||
? LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS * maxSeriesForQuery +
|
||||
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
|
||||
: LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS +
|
||||
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS;
|
||||
|
||||
const userTimezone = configuration.timezone ?? 'UTC';
|
||||
const firstDayOfTheWeek: CalendarStartDay =
|
||||
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
|
||||
CalendarStartDay.MONDAY;
|
||||
|
||||
const objectIdByNameSingular: Record<string, string> = {};
|
||||
|
||||
for (const objectId in flatObjectMetadataMaps.byId) {
|
||||
const objMetadata = flatObjectMetadataMaps.byId[objectId];
|
||||
|
||||
if (isDefined(objMetadata)) {
|
||||
objectIdByNameSingular[objMetadata.nameSingular] = objectId;
|
||||
}
|
||||
}
|
||||
|
||||
const rawResults = await this.chartDataQueryService.executeGroupByQuery({
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
authContext,
|
||||
groupByFieldMetadataId: configuration.primaryAxisGroupByFieldMetadataId,
|
||||
groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
|
||||
aggregateOperation: configuration.aggregateOperation,
|
||||
filter: configuration.filter,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
limit,
|
||||
primaryAxisOrderBy: configuration.primaryAxisOrderBy,
|
||||
secondaryGroupByFieldMetadataId:
|
||||
configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
secondaryGroupBySubFieldName:
|
||||
configuration.secondaryAxisGroupBySubFieldName,
|
||||
secondaryDateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity,
|
||||
secondaryAxisOrderBy: configuration.secondaryAxisOrderBy,
|
||||
});
|
||||
|
||||
if (isTwoDimensional && isDefined(secondaryAxisGroupByField)) {
|
||||
return this.transformToTwoDimensionalLineChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
}
|
||||
|
||||
return this.transformToOneDimensionalLineChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Line chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private transformToOneDimensionalLineChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
aggregateField: FlatFieldMetadata;
|
||||
configuration: LineChartConfigurationDTO;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
}): LineChartDataOutputDTO {
|
||||
const filteredResults = configuration.omitNullValues
|
||||
? rawResults.filter(
|
||||
(result) =>
|
||||
isDefined(result.groupByDimensionValues?.[0]) &&
|
||||
result.aggregateValue !== 0,
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!configuration.isCumulative &&
|
||||
(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,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
isDescOrder,
|
||||
isTwoDimensional: false,
|
||||
});
|
||||
|
||||
const selectOptions = getSelectOptions(primaryAxisGroupByField);
|
||||
|
||||
const convertedFirstDayOfTheWeek =
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
const {
|
||||
processedDataPoints: rawProcessedDataPoints,
|
||||
formattedToRawLookup,
|
||||
} = processOneDimensionalResults({
|
||||
rawResults: gapFilledResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const processedDataPoints = rawProcessedDataPoints.map((point) => ({
|
||||
x: point.formattedValue,
|
||||
y: point.aggregateValue,
|
||||
rawValue: point.rawValue,
|
||||
}));
|
||||
|
||||
const sortedData = sortChartDataIfNeeded({
|
||||
data: processedDataPoints,
|
||||
orderBy: configuration.primaryAxisOrderBy,
|
||||
manualSortOrder: configuration.primaryAxisManualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue: (item) => item.x,
|
||||
getNumericValue: (item) => item.y ?? 0,
|
||||
selectFieldOptions: selectOptions,
|
||||
fieldType: primaryAxisGroupByField.type,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
});
|
||||
|
||||
const limitedSortedData = sortedData.slice(
|
||||
0,
|
||||
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
|
||||
);
|
||||
|
||||
const transformedData = configuration.isCumulative
|
||||
? this.applyCumulativeTransform(
|
||||
limitedSortedData,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
)
|
||||
: limitedSortedData;
|
||||
|
||||
const dataPoints = transformedData.map(({ x, y }) => ({
|
||||
x,
|
||||
y,
|
||||
}));
|
||||
|
||||
const series = [
|
||||
{
|
||||
id: aggregateField.name,
|
||||
label: aggregateField.label,
|
||||
data: dataPoints,
|
||||
},
|
||||
];
|
||||
|
||||
const xAxisLabel = primaryAxisGroupByField.label;
|
||||
const yAxisLabel = `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`;
|
||||
|
||||
return {
|
||||
series,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showLegend: configuration.displayLegend ?? true,
|
||||
showDataLabels: configuration.displayDataLabel ?? false,
|
||||
hasTooManyGroups:
|
||||
filteredResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS ||
|
||||
dateRangeWasTruncated,
|
||||
formattedToRawLookup: Object.fromEntries(formattedToRawLookup),
|
||||
};
|
||||
}
|
||||
|
||||
private transformToTwoDimensionalLineChartData({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
aggregateField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
secondaryAxisGroupByField: FlatFieldMetadata;
|
||||
aggregateField: FlatFieldMetadata;
|
||||
configuration: LineChartConfigurationDTO;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
}): LineChartDataOutputDTO {
|
||||
const filteredResults = configuration.omitNullValues
|
||||
? rawResults.filter(
|
||||
(result) =>
|
||||
isDefined(result.groupByDimensionValues?.[0]) &&
|
||||
result.aggregateValue !== 0,
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const isStacked = configuration.isStacked ?? false;
|
||||
|
||||
const rangeFilteredResults =
|
||||
!configuration.isCumulative &&
|
||||
!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,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
isDescOrder,
|
||||
isTwoDimensional: true,
|
||||
});
|
||||
|
||||
const primarySelectOptions = getSelectOptions(primaryAxisGroupByField);
|
||||
const secondarySelectOptions = getSelectOptions(secondaryAxisGroupByField);
|
||||
|
||||
const convertedFirstDayOfTheWeek =
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
const {
|
||||
processedDataPoints: rawProcessedDataPoints,
|
||||
formattedToRawLookup,
|
||||
secondaryFormattedToRawLookup,
|
||||
} = processTwoDimensionalResults({
|
||||
rawResults: gapFilledResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
primaryDateGranularity: configuration.primaryAxisDateGranularity,
|
||||
primarySubFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
secondaryDateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity,
|
||||
secondarySubFieldName: configuration.secondaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const allXValues: string[] = [];
|
||||
const xValueSet = new Set<string>();
|
||||
const allSeriesIds = new Set<string>();
|
||||
|
||||
const processedDataPoints = rawProcessedDataPoints.map((point) => {
|
||||
if (!xValueSet.has(point.xFormatted)) {
|
||||
xValueSet.add(point.xFormatted);
|
||||
allXValues.push(point.xFormatted);
|
||||
}
|
||||
|
||||
allSeriesIds.add(point.yFormatted);
|
||||
|
||||
return {
|
||||
xFormatted: point.xFormatted,
|
||||
ySeriesId: point.yFormatted,
|
||||
rawXValue: point.rawXValue,
|
||||
rawYValue: point.rawYValue,
|
||||
aggregateValue: point.aggregateValue,
|
||||
};
|
||||
});
|
||||
|
||||
const seriesMap = new Map<string, Map<string, number>>();
|
||||
|
||||
for (const point of processedDataPoints) {
|
||||
if (!seriesMap.has(point.ySeriesId)) {
|
||||
seriesMap.set(point.ySeriesId, new Map());
|
||||
}
|
||||
|
||||
seriesMap
|
||||
.get(point.ySeriesId)!
|
||||
.set(point.xFormatted, point.aggregateValue);
|
||||
}
|
||||
|
||||
const sortedXValues = sortChartDataIfNeeded({
|
||||
data: allXValues,
|
||||
orderBy: configuration.primaryAxisOrderBy,
|
||||
manualSortOrder: configuration.primaryAxisManualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue: (x) => x,
|
||||
getNumericValue: (xValue) => {
|
||||
let sum = 0;
|
||||
|
||||
for (const xToYMap of seriesMap.values()) {
|
||||
const value = xToYMap.get(xValue);
|
||||
|
||||
if (isDefined(value)) {
|
||||
sum += value;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
},
|
||||
selectFieldOptions: primarySelectOptions,
|
||||
fieldType: primaryAxisGroupByField.type,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity,
|
||||
});
|
||||
|
||||
const limitedXValues = sortedXValues.slice(
|
||||
0,
|
||||
LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS,
|
||||
);
|
||||
|
||||
const seriesIds = Array.from(allSeriesIds);
|
||||
|
||||
const sortedSeriesIds = this.sortSecondaryAxisSeriesIds({
|
||||
seriesIds,
|
||||
seriesMap,
|
||||
configuration,
|
||||
secondaryFormattedToRawLookup,
|
||||
secondarySelectOptions,
|
||||
secondaryAxisGroupByField,
|
||||
});
|
||||
|
||||
const maxSeries = isStacked
|
||||
? LINE_CHART_MAXIMUM_NUMBER_OF_STACKED_SERIES
|
||||
: LINE_CHART_MAXIMUM_NUMBER_OF_NON_STACKED_SERIES;
|
||||
|
||||
const limitedSeriesIds = sortedSeriesIds.slice(0, maxSeries);
|
||||
|
||||
const filteredXValues =
|
||||
!configuration.isCumulative &&
|
||||
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();
|
||||
|
||||
let dataPoints = filteredXValues.map((xValue) => ({
|
||||
x: xValue,
|
||||
y: xToYMap.get(xValue) ?? 0,
|
||||
}));
|
||||
|
||||
if (configuration.isCumulative) {
|
||||
dataPoints = this.applyCumulativeTransform(
|
||||
dataPoints,
|
||||
configuration.rangeMin,
|
||||
configuration.rangeMax,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: seriesId,
|
||||
label: seriesId,
|
||||
data: dataPoints,
|
||||
};
|
||||
});
|
||||
|
||||
const xAxisLabel = primaryAxisGroupByField.label;
|
||||
const yAxisLabel = `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`;
|
||||
|
||||
const hasTooManySeries = seriesIds.length > maxSeries;
|
||||
const hasTooManyDataPoints =
|
||||
allXValues.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS;
|
||||
const hasTooManyGroups =
|
||||
hasTooManySeries || hasTooManyDataPoints || dateRangeWasTruncated;
|
||||
|
||||
const mergedLookup = new Map([
|
||||
...formattedToRawLookup,
|
||||
...secondaryFormattedToRawLookup,
|
||||
]);
|
||||
|
||||
return {
|
||||
series,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showLegend: configuration.displayLegend ?? true,
|
||||
showDataLabels: configuration.displayDataLabel ?? false,
|
||||
hasTooManyGroups,
|
||||
formattedToRawLookup: Object.fromEntries(mergedLookup),
|
||||
};
|
||||
}
|
||||
|
||||
private sortSecondaryAxisSeriesIds({
|
||||
seriesIds,
|
||||
seriesMap,
|
||||
configuration,
|
||||
secondaryFormattedToRawLookup,
|
||||
secondarySelectOptions,
|
||||
secondaryAxisGroupByField,
|
||||
}: {
|
||||
seriesIds: string[];
|
||||
seriesMap: Map<string, Map<string, number>>;
|
||||
configuration: LineChartConfigurationDTO;
|
||||
secondaryFormattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
secondarySelectOptions: FieldMetadataOption[] | null;
|
||||
secondaryAxisGroupByField: FlatFieldMetadata;
|
||||
}): string[] {
|
||||
const orderBy = configuration.secondaryAxisOrderBy;
|
||||
|
||||
if (!isDefined(orderBy)) {
|
||||
return seriesIds;
|
||||
}
|
||||
|
||||
return sortSecondaryAxisData({
|
||||
items: seriesIds,
|
||||
orderBy,
|
||||
manualSortOrder: configuration.secondaryAxisManualSortOrder,
|
||||
formattedToRawLookup: secondaryFormattedToRawLookup,
|
||||
getFormattedValue: (id) => id,
|
||||
getNumericValue: (id) => {
|
||||
const xToYMap = seriesMap.get(id);
|
||||
|
||||
if (!xToYMap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sum = 0;
|
||||
|
||||
for (const value of xToYMap.values()) {
|
||||
sum += value;
|
||||
}
|
||||
|
||||
return sum;
|
||||
},
|
||||
selectFieldOptions: secondarySelectOptions,
|
||||
fieldType: secondaryAxisGroupByField.type,
|
||||
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
|
||||
dateGranularity: configuration.secondaryAxisGroupByDateGranularity,
|
||||
});
|
||||
}
|
||||
|
||||
private applyCumulativeTransform<T extends { y: number | null }>(
|
||||
data: T[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): T[] {
|
||||
const result: T[] = [];
|
||||
let runningTotal = 0;
|
||||
|
||||
for (const point of data) {
|
||||
if (isDefined(point.y)) {
|
||||
runningTotal += point.y;
|
||||
}
|
||||
|
||||
const cumulativeValue = runningTotal;
|
||||
|
||||
const isOutOfRange =
|
||||
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
|
||||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
|
||||
|
||||
if (!isOutOfRange) {
|
||||
result.push({ ...point, y: cumulativeValue });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant';
|
||||
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from 'src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant';
|
||||
import { PieChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-output.dto';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
generateChartDataExceptionMessage,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
|
||||
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';
|
||||
import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
|
||||
type GetPieChartDataParams = {
|
||||
workspaceId: string;
|
||||
objectMetadataId: string;
|
||||
configuration: PieChartConfigurationDTO;
|
||||
authContext: AuthContext;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PieChartDataService {
|
||||
constructor(
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly chartDataQueryService: ChartDataQueryService,
|
||||
) {}
|
||||
|
||||
async getPieChartData({
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
configuration,
|
||||
authContext,
|
||||
}: GetPieChartDataParams): Promise<PieChartDataOutputDTO> {
|
||||
try {
|
||||
if (
|
||||
configuration.configurationType !== WidgetConfigurationType.PIE_CHART
|
||||
) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
|
||||
`Expected PIE_CHART, got ${configuration.configurationType}`,
|
||||
),
|
||||
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
|
||||
);
|
||||
}
|
||||
|
||||
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
'Widget has no objectMetadataId',
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const flatObjectMetadata = flatObjectMetadataMaps.byId[objectMetadataId];
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
objectMetadataId,
|
||||
),
|
||||
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const groupByField = getFieldMetadata(
|
||||
configuration.groupByFieldMetadataId,
|
||||
flatFieldMetadataMaps.byId,
|
||||
);
|
||||
|
||||
const limit =
|
||||
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES +
|
||||
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS;
|
||||
|
||||
const objectIdByNameSingular: Record<string, string> = {};
|
||||
|
||||
for (const objectId in flatObjectMetadataMaps.byId) {
|
||||
const objMetadata = flatObjectMetadataMaps.byId[objectId];
|
||||
|
||||
if (isDefined(objMetadata)) {
|
||||
objectIdByNameSingular[objMetadata.nameSingular] = objectId;
|
||||
}
|
||||
}
|
||||
|
||||
const rawResults = await this.chartDataQueryService.executeGroupByQuery({
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
authContext,
|
||||
groupByFieldMetadataId: configuration.groupByFieldMetadataId,
|
||||
groupBySubFieldName: configuration.groupBySubFieldName,
|
||||
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
|
||||
aggregateOperation: configuration.aggregateOperation,
|
||||
filter: configuration.filter,
|
||||
dateGranularity: configuration.dateGranularity,
|
||||
userTimezone: configuration.timezone ?? 'UTC',
|
||||
firstDayOfTheWeek:
|
||||
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
|
||||
CalendarStartDay.MONDAY,
|
||||
limit,
|
||||
primaryAxisOrderBy: configuration.orderBy,
|
||||
});
|
||||
|
||||
return this.transformToPieChartData({
|
||||
rawResults,
|
||||
groupByField,
|
||||
configuration,
|
||||
userTimezone: configuration.timezone ?? 'UTC',
|
||||
firstDayOfTheWeek:
|
||||
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
|
||||
CalendarStartDay.MONDAY,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ChartDataException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
`Pie chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private transformToPieChartData({
|
||||
rawResults,
|
||||
groupByField,
|
||||
configuration,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: {
|
||||
rawResults: Array<{
|
||||
groupByDimensionValues: unknown[];
|
||||
aggregateValue: number;
|
||||
}>;
|
||||
groupByField: FlatFieldMetadata;
|
||||
configuration: PieChartConfigurationDTO;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: CalendarStartDay;
|
||||
}): PieChartDataOutputDTO {
|
||||
const filteredResults = configuration.hideEmptyCategory
|
||||
? rawResults.filter(
|
||||
(result) =>
|
||||
isDefined(result.groupByDimensionValues?.[0]) &&
|
||||
result.aggregateValue !== 0,
|
||||
)
|
||||
: rawResults;
|
||||
|
||||
const selectOptions = getSelectOptions(groupByField);
|
||||
|
||||
const convertedFirstDayOfTheWeek =
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
const limitedResults = filteredResults.slice(
|
||||
0,
|
||||
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
|
||||
);
|
||||
|
||||
const {
|
||||
processedDataPoints: rawProcessedDataPoints,
|
||||
formattedToRawLookup,
|
||||
} = processOneDimensionalResults({
|
||||
rawResults: limitedResults,
|
||||
primaryAxisGroupByField: groupByField,
|
||||
dateGranularity: configuration.dateGranularity,
|
||||
subFieldName: configuration.groupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const processedDataPoints = rawProcessedDataPoints.map((point) => {
|
||||
const rawValueString = isDefined(point.rawValue)
|
||||
? String(point.rawValue)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: point.formattedValue,
|
||||
value: point.aggregateValue,
|
||||
rawValue: rawValueString,
|
||||
};
|
||||
});
|
||||
|
||||
const sortedData = sortChartDataIfNeeded({
|
||||
data: processedDataPoints,
|
||||
orderBy: configuration.orderBy,
|
||||
manualSortOrder: configuration.manualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue: (item) => item.id,
|
||||
getNumericValue: (item) => item.value,
|
||||
selectFieldOptions: selectOptions,
|
||||
fieldType: groupByField.type,
|
||||
dateGranularity: configuration.dateGranularity,
|
||||
});
|
||||
|
||||
const data = sortedData.map(({ rawValue: _rawValue, ...item }) => item);
|
||||
|
||||
return {
|
||||
data,
|
||||
showLegend: configuration.displayLegend ?? true,
|
||||
showDataLabels: configuration.displayDataLabel ?? false,
|
||||
showCenterMetric: configuration.showCenterMetric ?? true,
|
||||
hasTooManyGroups:
|
||||
filteredResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
|
||||
formattedToRawLookup: Object.fromEntries(formattedToRawLookup),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user