[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:
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { PageLayoutWidgetModule } from 'src/engine/metadata-modules/page-layout-widget/page-layout-widget.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { BarChartDataResolver } from 'src/modules/dashboard/chart-data/resolvers/bar-chart-data.resolver';
|
||||
import { LineChartDataResolver } from 'src/modules/dashboard/chart-data/resolvers/line-chart-data.resolver';
|
||||
import { PieChartDataResolver } from 'src/modules/dashboard/chart-data/resolvers/pie-chart-data.resolver';
|
||||
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';
|
||||
import { LineChartDataService } from 'src/modules/dashboard/chart-data/services/line-chart-data.service';
|
||||
import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/pie-chart-data.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CoreCommonApiModule,
|
||||
PageLayoutWidgetModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
TwentyORMModule,
|
||||
],
|
||||
providers: [
|
||||
ChartDataQueryService,
|
||||
PieChartDataService,
|
||||
PieChartDataResolver,
|
||||
LineChartDataService,
|
||||
LineChartDataResolver,
|
||||
BarChartDataService,
|
||||
BarChartDataResolver,
|
||||
],
|
||||
exports: [PieChartDataService, LineChartDataService, BarChartDataService],
|
||||
})
|
||||
export class ChartDataModule {}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_MAXIMUM_NUMBER_OF_BARS = 100;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR = 50;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export const DATE_GRANULARITIES_WITHOUT_GAP_FILLING = new Set([
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR,
|
||||
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR,
|
||||
ObjectRecordGroupByDateGranularity.NONE,
|
||||
]);
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export const GRAPH_DEFAULT_DATE_GRANULARITY =
|
||||
ObjectRecordGroupByDateGranularity.DAY;
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
|
||||
export const GRAPH_DEFAULT_ORDER_BY = GraphOrderBy.FIELD_ASC;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS = 100;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MAXIMUM_NUMBER_OF_NON_STACKED_SERIES = 50;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const LINE_CHART_MAXIMUM_NUMBER_OF_STACKED_SERIES = 50;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const PIE_CHART_MAXIMUM_NUMBER_OF_SLICES = 100;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export type SupportedDateGranularityForGapFilling =
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.MONTH
|
||||
| ObjectRecordGroupByDateGranularity.QUARTER
|
||||
| ObjectRecordGroupByDateGranularity.YEAR
|
||||
| ObjectRecordGroupByDateGranularity.WEEK;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
|
||||
|
||||
@InputType()
|
||||
export class BarChartDataInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
objectMetadataId: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
@ValidateNested()
|
||||
@Type(() => BarChartConfigurationDTO)
|
||||
@IsNotEmpty()
|
||||
configuration: BarChartConfigurationDTO;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
|
||||
|
||||
@InputType()
|
||||
export class LineChartDataInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
objectMetadataId: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
@ValidateNested()
|
||||
@Type(() => LineChartConfigurationDTO)
|
||||
@IsNotEmpty()
|
||||
configuration: LineChartConfigurationDTO;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
|
||||
|
||||
@InputType()
|
||||
export class PieChartDataInput {
|
||||
@Field(() => UUIDScalarType)
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
objectMetadataId: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
@ValidateNested()
|
||||
@Type(() => PieChartConfigurationDTO)
|
||||
@IsNotEmpty()
|
||||
configuration: PieChartConfigurationDTO;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
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 { BarChartSeriesDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/bar-chart-series.dto';
|
||||
|
||||
@ObjectType('BarChartDataOutput')
|
||||
export class BarChartDataOutputDTO {
|
||||
@Field(() => [GraphQLJSON])
|
||||
data: Record<string, string | number>[];
|
||||
|
||||
@Field(() => String)
|
||||
indexBy: string;
|
||||
|
||||
@Field(() => [String])
|
||||
keys: string[];
|
||||
|
||||
@Field(() => [BarChartSeriesDTO])
|
||||
series: BarChartSeriesDTO[];
|
||||
|
||||
@Field(() => String)
|
||||
xAxisLabel: string;
|
||||
|
||||
@Field(() => String)
|
||||
yAxisLabel: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showLegend: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showDataLabels: boolean;
|
||||
|
||||
@Field(() => BarChartLayout)
|
||||
layout: BarChartLayout;
|
||||
|
||||
@Field(() => BarChartGroupMode)
|
||||
groupMode: BarChartGroupMode;
|
||||
|
||||
@Field(() => Boolean)
|
||||
hasTooManyGroups: boolean;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
formattedToRawLookup: Record<string, unknown>;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BarChartSeries')
|
||||
export class BarChartSeriesDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String)
|
||||
label: string;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { LineChartSeriesDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/line-chart-series.dto';
|
||||
|
||||
@ObjectType('LineChartDataOutput')
|
||||
export class LineChartDataOutputDTO {
|
||||
@Field(() => [LineChartSeriesDTO])
|
||||
series: LineChartSeriesDTO[];
|
||||
|
||||
@Field(() => String)
|
||||
xAxisLabel: string;
|
||||
|
||||
@Field(() => String)
|
||||
yAxisLabel: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showLegend: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showDataLabels: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
hasTooManyGroups: boolean;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
formattedToRawLookup: Record<string, unknown>;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('LineChartDataPoint')
|
||||
export class LineChartDataPointDTO {
|
||||
@Field(() => String)
|
||||
x: string;
|
||||
|
||||
@Field(() => Float)
|
||||
y: number;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { LineChartDataPointDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-point.dto';
|
||||
|
||||
@ObjectType('LineChartSeries')
|
||||
export class LineChartSeriesDTO {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
label: string;
|
||||
|
||||
@Field(() => [LineChartDataPointDTO])
|
||||
data: LineChartDataPointDTO[];
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('PieChartDataItem')
|
||||
export class PieChartDataItemDTO {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => Float)
|
||||
value: number;
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { PieChartDataItemDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-item.dto';
|
||||
|
||||
@ObjectType('PieChartDataOutput')
|
||||
export class PieChartDataOutputDTO {
|
||||
@Field(() => [PieChartDataItemDTO])
|
||||
data: PieChartDataItemDTO[];
|
||||
|
||||
@Field(() => Boolean)
|
||||
showLegend: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showDataLabels: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
showCenterMetric: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
hasTooManyGroups: boolean;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
formattedToRawLookup: Record<string, unknown>;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ChartDataExceptionCode {
|
||||
WIDGET_NOT_FOUND = 'WIDGET_NOT_FOUND',
|
||||
INVALID_WIDGET_CONFIGURATION = 'INVALID_WIDGET_CONFIGURATION',
|
||||
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
|
||||
FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND',
|
||||
QUERY_EXECUTION_FAILED = 'QUERY_EXECUTION_FAILED',
|
||||
TRANSFORMATION_FAILED = 'TRANSFORMATION_FAILED',
|
||||
}
|
||||
|
||||
const getChartDataExceptionUserFriendlyMessage = (
|
||||
code: ChartDataExceptionCode,
|
||||
): MessageDescriptor => {
|
||||
switch (code) {
|
||||
case ChartDataExceptionCode.WIDGET_NOT_FOUND:
|
||||
return msg`Widget not found.`;
|
||||
case ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION:
|
||||
return msg`Invalid widget configuration.`;
|
||||
case ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
return msg`Object metadata not found.`;
|
||||
case ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND:
|
||||
return msg`Field metadata not found.`;
|
||||
case ChartDataExceptionCode.QUERY_EXECUTION_FAILED:
|
||||
return msg`Query execution failed.`;
|
||||
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
|
||||
return msg`Transformation failed.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class ChartDataException extends CustomException<ChartDataExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ChartDataExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ?? getChartDataExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const generateChartDataExceptionMessage = (
|
||||
code: ChartDataExceptionCode,
|
||||
context?: string,
|
||||
): string => {
|
||||
const messages: Record<ChartDataExceptionCode, string> = {
|
||||
[ChartDataExceptionCode.WIDGET_NOT_FOUND]: `Widget not found${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION]: `Invalid widget configuration${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND]: `Object metadata not found${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND]: `Field metadata not found${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.QUERY_EXECUTION_FAILED]: `Query execution failed${context ? `: ${context}` : ''}`,
|
||||
[ChartDataExceptionCode.TRANSFORMATION_FAILED]: `Transformation failed${context ? `: ${context}` : ''}`,
|
||||
};
|
||||
|
||||
return messages[code];
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { ArgumentsHost, Catch } from '@nestjs/common';
|
||||
import { GqlExceptionFilter } from '@nestjs/graphql';
|
||||
|
||||
import { ChartDataException } from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
import { chartDataGraphqlApiExceptionHandler } from 'src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util';
|
||||
|
||||
@Catch(ChartDataException)
|
||||
export class ChartDataGraphqlApiExceptionFilter implements GqlExceptionFilter {
|
||||
catch(exception: ChartDataException, _host: ArgumentsHost) {
|
||||
return chartDataGraphqlApiExceptionHandler(exception);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { BarChartDataInput } from 'src/modules/dashboard/chart-data/dtos/inputs/bar-chart-data.input';
|
||||
import { BarChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/bar-chart-data-output.dto';
|
||||
import { ChartDataGraphqlApiExceptionFilter } from 'src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter';
|
||||
import { BarChartDataService } from 'src/modules/dashboard/chart-data/services/bar-chart-data.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(ChartDataGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class BarChartDataResolver {
|
||||
constructor(private readonly barChartDataService: BarChartDataService) {}
|
||||
|
||||
@Query(() => BarChartDataOutputDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async barChartData(
|
||||
@Args('input') input: BarChartDataInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUser() user: UserEntity,
|
||||
@AuthWorkspaceMemberId() workspaceMemberId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<BarChartDataOutputDTO> {
|
||||
const authContext: AuthContext = {
|
||||
user,
|
||||
workspace,
|
||||
workspaceMemberId,
|
||||
userWorkspaceId,
|
||||
};
|
||||
|
||||
return this.barChartDataService.getBarChartData({
|
||||
objectMetadataId: input.objectMetadataId,
|
||||
configuration: input.configuration,
|
||||
workspaceId: workspace.id,
|
||||
authContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { LineChartDataInput } from 'src/modules/dashboard/chart-data/dtos/inputs/line-chart-data.input';
|
||||
import { LineChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-output.dto';
|
||||
import { ChartDataGraphqlApiExceptionFilter } from 'src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter';
|
||||
import { LineChartDataService } from 'src/modules/dashboard/chart-data/services/line-chart-data.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(ChartDataGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class LineChartDataResolver {
|
||||
constructor(private readonly lineChartDataService: LineChartDataService) {}
|
||||
|
||||
@Query(() => LineChartDataOutputDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async lineChartData(
|
||||
@Args('input') input: LineChartDataInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUser() user: UserEntity,
|
||||
@AuthWorkspaceMemberId() workspaceMemberId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<LineChartDataOutputDTO> {
|
||||
const authContext: AuthContext = {
|
||||
user,
|
||||
workspace,
|
||||
workspaceMemberId,
|
||||
userWorkspaceId,
|
||||
};
|
||||
|
||||
return this.lineChartDataService.getLineChartData({
|
||||
objectMetadataId: input.objectMetadataId,
|
||||
configuration: input.configuration,
|
||||
workspaceId: workspace.id,
|
||||
authContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PieChartDataInput } from 'src/modules/dashboard/chart-data/dtos/inputs/pie-chart-data.input';
|
||||
import { PieChartDataOutputDTO } from 'src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-output.dto';
|
||||
import { ChartDataGraphqlApiExceptionFilter } from 'src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter';
|
||||
import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/pie-chart-data.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(ChartDataGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class PieChartDataResolver {
|
||||
constructor(private readonly pieChartDataService: PieChartDataService) {}
|
||||
|
||||
@Query(() => PieChartDataOutputDTO)
|
||||
@UseGuards(NoPermissionGuard)
|
||||
async pieChartData(
|
||||
@Args('input') input: PieChartDataInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUser() user: UserEntity,
|
||||
@AuthWorkspaceMemberId() workspaceMemberId: string,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string,
|
||||
): Promise<PieChartDataOutputDTO> {
|
||||
const authContext: AuthContext = {
|
||||
user,
|
||||
workspace,
|
||||
workspaceMemberId,
|
||||
userWorkspaceId,
|
||||
};
|
||||
|
||||
return this.pieChartDataService.getPieChartData({
|
||||
objectMetadataId: input.objectMetadataId,
|
||||
configuration: input.configuration,
|
||||
workspaceId: workspace.id,
|
||||
authContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
+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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export type FieldMetadataOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
color?: string;
|
||||
position: number;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type GroupByRawResult = {
|
||||
groupByDimensionValues: unknown[];
|
||||
aggregateValue: number;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type RawDimensionValue = string | number | boolean | Date | null;
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { buildGroupByFieldObject } from 'src/modules/dashboard/chart-data/utils/build-group-by-field-object.util';
|
||||
|
||||
const createMockFieldMetadata = (overrides: Partial<FlatFieldMetadata>) =>
|
||||
({
|
||||
id: 'test-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
description: null,
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
describe('buildGroupByFieldObject', () => {
|
||||
describe('relation fields', () => {
|
||||
it('should return field with Id suffix for relation fields without subFieldName', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({ fieldMetadata });
|
||||
|
||||
expect(result).toEqual({ companyId: true });
|
||||
});
|
||||
|
||||
it('should return nested object for relation field with subFieldName', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'name',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ company: { name: true } });
|
||||
});
|
||||
|
||||
it('should return deeply nested object for relation with composite subfield', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'address.addressCity',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: { address: { addressCity: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return date granularity for relation date field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for nested date field without time zone when required', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
isNestedDateField: true,
|
||||
}),
|
||||
).toThrow('Date group by should have a time zone.');
|
||||
});
|
||||
|
||||
it('should include weekStartDay for nested date field with WEEK granularity and MONDAY', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'MONDAY',
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include weekStartDay for nested date field with WEEK granularity and SUNDAY', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SUNDAY,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'SUNDAY',
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include weekStartDay for nested date field with WEEK granularity and SYSTEM', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SYSTEM,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include weekStartDay for nested date field with non-WEEK granularity', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-object-id',
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('composite fields', () => {
|
||||
it('should return nested object for composite fields with subfield', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'firstName',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: {
|
||||
firstName: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for composite field without subfield', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
|
||||
expect(() => buildGroupByFieldObject({ fieldMetadata })).toThrow(
|
||||
'Composite field name requires a subfield to be specified',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ADDRESS composite field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
subFieldName: 'addressCity',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
address: {
|
||||
addressCity: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('date fields', () => {
|
||||
it('should return field with default granularity for DATE field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return field with default granularity for DATE_TIME field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return field with custom granularity for date field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for date field without time zone when required', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
}),
|
||||
).toThrow('Date group by should have a time zone.');
|
||||
});
|
||||
|
||||
it('should include weekStartDay for WEEK granularity with MONDAY', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'MONDAY',
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include weekStartDay for WEEK granularity with SUNDAY', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SUNDAY,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'SUNDAY',
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include weekStartDay for WEEK granularity with SYSTEM', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SYSTEM,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include weekStartDay for non-WEEK granularity', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
fieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('regular fields', () => {
|
||||
it('should return simple field object for TEXT field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({ fieldMetadata });
|
||||
|
||||
expect(result).toEqual({ status: true });
|
||||
});
|
||||
|
||||
it('should return simple field object for SELECT field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'priority',
|
||||
type: FieldMetadataType.SELECT,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({ fieldMetadata });
|
||||
|
||||
expect(result).toEqual({ priority: true });
|
||||
});
|
||||
|
||||
it('should return simple field object for NUMBER field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'quantity',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({ fieldMetadata });
|
||||
|
||||
expect(result).toEqual({ quantity: true });
|
||||
});
|
||||
|
||||
it('should return simple field object for BOOLEAN field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'isActive',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
});
|
||||
|
||||
const result = buildGroupByFieldObject({ fieldMetadata });
|
||||
|
||||
expect(result).toEqual({ isActive: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { compareDimensionValues } from 'src/modules/dashboard/chart-data/utils/compare-dimension-values.util';
|
||||
|
||||
describe('compareDimensionValues', () => {
|
||||
describe('string comparison (default)', () => {
|
||||
it('should compare strings alphabetically in ascending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 'Alpha',
|
||||
rawValueB: 'Beta',
|
||||
formattedValueA: 'Alpha',
|
||||
formattedValueB: 'Beta',
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should compare strings alphabetically in descending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 'Alpha',
|
||||
rawValueB: 'Beta',
|
||||
formattedValueA: 'Alpha',
|
||||
formattedValueB: 'Beta',
|
||||
direction: 'DESC',
|
||||
});
|
||||
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return 0 for equal strings', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 'Alpha',
|
||||
rawValueB: 'Alpha',
|
||||
formattedValueA: 'Alpha',
|
||||
formattedValueB: 'Alpha',
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date comparison', () => {
|
||||
it('should compare dates in ascending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '2024-01-01',
|
||||
rawValueB: '2024-02-01',
|
||||
formattedValueA: 'Jan 1, 2024',
|
||||
formattedValueB: 'Feb 1, 2024',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should compare dates in descending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '2024-01-01',
|
||||
rawValueB: '2024-02-01',
|
||||
formattedValueA: 'Jan 1, 2024',
|
||||
formattedValueB: 'Feb 1, 2024',
|
||||
direction: 'DESC',
|
||||
fieldType: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return 0 for equal dates', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '2024-01-01',
|
||||
rawValueB: '2024-01-01',
|
||||
formattedValueA: 'Jan 1, 2024',
|
||||
formattedValueB: 'Jan 1, 2024',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should compare datetime values', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '2024-01-01',
|
||||
rawValueB: '2024-06-15',
|
||||
formattedValueA: 'Jan 1, 2024',
|
||||
formattedValueB: 'Jun 15, 2024',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.DATE_TIME,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numeric comparison', () => {
|
||||
it('should compare numbers in ascending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 100,
|
||||
rawValueB: 200,
|
||||
formattedValueA: '100',
|
||||
formattedValueB: '200',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should compare numbers in descending order', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 100,
|
||||
rawValueB: 200,
|
||||
formattedValueA: '100',
|
||||
formattedValueB: '200',
|
||||
direction: 'DESC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return 0 for equal numbers', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 100,
|
||||
rawValueB: 100,
|
||||
formattedValueA: '100',
|
||||
formattedValueB: '100',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle string number values', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '100',
|
||||
rawValueB: '200',
|
||||
formattedValueA: '100',
|
||||
formattedValueB: '200',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('currency comparison', () => {
|
||||
it('should compare currency amountMicros numerically', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 1000000,
|
||||
rawValueB: 2000000,
|
||||
formattedValueA: '$1.00',
|
||||
formattedValueB: '$2.00',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.CURRENCY,
|
||||
subFieldName: 'amountMicros',
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should compare currency code alphabetically', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 'EUR',
|
||||
rawValueB: 'USD',
|
||||
formattedValueA: 'EUR',
|
||||
formattedValueB: 'USD',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.CURRENCY,
|
||||
subFieldName: 'currencyCode',
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should fall back to string comparison when raw values are undefined', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: undefined,
|
||||
rawValueB: undefined,
|
||||
formattedValueA: 'Alpha',
|
||||
formattedValueB: 'Beta',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should use formatted values when one raw value is undefined', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: 100,
|
||||
rawValueB: undefined,
|
||||
formattedValueA: '100',
|
||||
formattedValueB: '200',
|
||||
direction: 'ASC',
|
||||
fieldType: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should use formatted values when field type is not provided', () => {
|
||||
const result = compareDimensionValues({
|
||||
rawValueA: '100',
|
||||
rawValueB: '200',
|
||||
formattedValueA: 'One Hundred',
|
||||
formattedValueB: 'Two Hundred',
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
// Uses localeCompare on formatted values
|
||||
expect(result).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
fillDateGaps,
|
||||
fillDateGapsTwoDimensional,
|
||||
} from 'src/modules/dashboard/chart-data/utils/fill-date-gaps.util';
|
||||
|
||||
describe('fillDateGaps', () => {
|
||||
describe('edge cases', () => {
|
||||
it('should return empty data unchanged', () => {
|
||||
const result = fillDateGaps({
|
||||
data: [],
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return data unchanged when dateGranularity is null', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: null,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return data unchanged when dateGranularity is undefined', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: undefined,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('granularities without gap filling', () => {
|
||||
it('should not fill gaps for DAY_OF_THE_WEEK granularity', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['Monday'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['Friday'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not fill gaps for MONTH_OF_THE_YEAR granularity', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['January'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['March'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not fill gaps for QUARTER_OF_THE_YEAR granularity', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['Q1'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['Q4'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not fill gaps for NONE granularity', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DAY granularity gap filling', () => {
|
||||
it('should fill missing days with zero values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.data[0]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
aggregateValue: 5,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-02'],
|
||||
aggregateValue: 0,
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
aggregateValue: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing data values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['2024-01-02'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 30 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].aggregateValue).toBe(10);
|
||||
expect(result.data[1].aggregateValue).toBe(20);
|
||||
expect(result.data[2].aggregateValue).toBe(30);
|
||||
});
|
||||
|
||||
it('should handle descending order', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-03'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
isDescOrder: true,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03');
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-02');
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MONTH granularity gap filling', () => {
|
||||
it('should fill missing months with zero values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-04-01'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(4);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-02-01');
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-03-01');
|
||||
expect(result.data[3].groupByDimensionValues[0]).toBe('2024-04-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEK granularity gap filling', () => {
|
||||
it('should fill missing weeks with zero values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-22'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGaps({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(4);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-08');
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-15');
|
||||
expect(result.data[3].groupByDimensionValues[0]).toBe('2024-01-22');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillDateGapsTwoDimensional', () => {
|
||||
describe('edge cases', () => {
|
||||
it('should return empty data unchanged', () => {
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data: [],
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return data unchanged when dateGranularity is null', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03', 'B'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data,
|
||||
dateGranularity: null,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('granularities without gap filling', () => {
|
||||
it('should not fill gaps for DAY_OF_THE_WEEK granularity', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['Monday', 'A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['Friday', 'B'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(data);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DAY granularity gap filling', () => {
|
||||
it('should fill missing date-secondary combinations with zero values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03', 'A'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['2024-01-01', 'B'], aggregateValue: 2 },
|
||||
];
|
||||
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(6);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
|
||||
// Check that all dates have both secondary values
|
||||
const day1A = result.data.find(
|
||||
(d) =>
|
||||
d.groupByDimensionValues[0] === '2024-01-01' &&
|
||||
d.groupByDimensionValues[1] === 'A',
|
||||
);
|
||||
const day1B = result.data.find(
|
||||
(d) =>
|
||||
d.groupByDimensionValues[0] === '2024-01-01' &&
|
||||
d.groupByDimensionValues[1] === 'B',
|
||||
);
|
||||
const day2A = result.data.find(
|
||||
(d) =>
|
||||
d.groupByDimensionValues[0] === '2024-01-02' &&
|
||||
d.groupByDimensionValues[1] === 'A',
|
||||
);
|
||||
const day2B = result.data.find(
|
||||
(d) =>
|
||||
d.groupByDimensionValues[0] === '2024-01-02' &&
|
||||
d.groupByDimensionValues[1] === 'B',
|
||||
);
|
||||
|
||||
expect(day1A?.aggregateValue).toBe(5);
|
||||
expect(day1B?.aggregateValue).toBe(2);
|
||||
expect(day2A?.aggregateValue).toBe(0);
|
||||
expect(day2B?.aggregateValue).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null secondary dimension values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-01', null], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['2024-01-03', null], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
|
||||
const day2Null = result.data.find(
|
||||
(d) =>
|
||||
d.groupByDimensionValues[0] === '2024-01-02' &&
|
||||
d.groupByDimensionValues[1] === null,
|
||||
);
|
||||
|
||||
expect(day2Null?.aggregateValue).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle descending order', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['2024-01-03', 'A'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['2024-01-01', 'A'], aggregateValue: 5 },
|
||||
];
|
||||
|
||||
const result = fillDateGapsTwoDimensional({
|
||||
data,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
isDescOrder: true,
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03');
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-02');
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
});
|
||||
});
|
||||
});
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
fillSelectGaps,
|
||||
fillSelectGapsTwoDimensional,
|
||||
} from 'src/modules/dashboard/chart-data/utils/fill-select-gaps.util';
|
||||
|
||||
describe('fillSelectGaps', () => {
|
||||
const selectOptions = [
|
||||
{ value: 'A', label: 'Option A', position: 0 },
|
||||
{ value: 'B', label: 'Option B', position: 1 },
|
||||
{ value: 'C', label: 'Option C', position: 2 },
|
||||
];
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return data unchanged when selectOptions is undefined', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return data unchanged when selectOptions is null', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return data unchanged when selectOptions is empty', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return empty data unchanged', () => {
|
||||
const result = fillSelectGaps({
|
||||
data: [],
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gap filling', () => {
|
||||
it('should fill missing select options with zero values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({
|
||||
groupByDimensionValues: ['A'],
|
||||
aggregateValue: 5,
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
groupByDimensionValues: ['B'],
|
||||
aggregateValue: 0,
|
||||
});
|
||||
expect(result[2]).toEqual({
|
||||
groupByDimensionValues: ['C'],
|
||||
aggregateValue: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing data values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 30 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].aggregateValue).toBe(10);
|
||||
expect(result[1].aggregateValue).toBe(20);
|
||||
expect(result[2].aggregateValue).toBe(30);
|
||||
});
|
||||
|
||||
it('should preserve selectOptions order', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 5 },
|
||||
];
|
||||
|
||||
const result = fillSelectGaps({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].groupByDimensionValues[0]).toBe('A');
|
||||
expect(result[1].groupByDimensionValues[0]).toBe('B');
|
||||
expect(result[2].groupByDimensionValues[0]).toBe('C');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fillSelectGapsTwoDimensional', () => {
|
||||
const selectOptions = [
|
||||
{ value: 'A', label: 'Option A', position: 0 },
|
||||
{ value: 'B', label: 'Option B', position: 1 },
|
||||
{ value: 'C', label: 'Option C', position: 2 },
|
||||
];
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return data unchanged when selectOptions is undefined', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B', 'X'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return data unchanged when selectOptions is null', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B', 'X'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return data unchanged when selectOptions is empty', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B', 'X'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return empty data unchanged', () => {
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data: [],
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gap filling', () => {
|
||||
it('should fill missing primary axis options for all secondary values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['C', 'X'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['A', 'Y'], aggregateValue: 2 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
expect(
|
||||
result.filter((r) => r.groupByDimensionValues[0] === 'A'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
result.filter((r) => r.groupByDimensionValues[0] === 'B'),
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
result.filter((r) => r.groupByDimensionValues[0] === 'C'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should create zero-value entries for missing combinations', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['C', 'Y'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
expect(
|
||||
result.find(
|
||||
(r) =>
|
||||
r.groupByDimensionValues[0] === 'B' &&
|
||||
r.groupByDimensionValues[1] === 'X',
|
||||
),
|
||||
).toEqual({
|
||||
groupByDimensionValues: ['B', 'X'],
|
||||
aggregateValue: 0,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.find(
|
||||
(r) =>
|
||||
r.groupByDimensionValues[0] === 'A' &&
|
||||
r.groupByDimensionValues[1] === 'Y',
|
||||
),
|
||||
).toEqual({
|
||||
groupByDimensionValues: ['A', 'Y'],
|
||||
aggregateValue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing combinations', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['B', 'X'], aggregateValue: 10 },
|
||||
{ groupByDimensionValues: ['C', 'X'], aggregateValue: 15 },
|
||||
{ groupByDimensionValues: ['A', 'Y'], aggregateValue: 20 },
|
||||
{ groupByDimensionValues: ['B', 'Y'], aggregateValue: 25 },
|
||||
{ groupByDimensionValues: ['C', 'Y'], aggregateValue: 30 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
|
||||
expect(
|
||||
result.find(
|
||||
(r) =>
|
||||
r.groupByDimensionValues[0] === 'A' &&
|
||||
r.groupByDimensionValues[1] === 'X',
|
||||
),
|
||||
).toEqual({
|
||||
groupByDimensionValues: ['A', 'X'],
|
||||
aggregateValue: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve selectOptions order for primary axis', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['C', 'X'], aggregateValue: 3 },
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
const primaryValues = result.map((r) => r.groupByDimensionValues[0]);
|
||||
const uniquePrimary = [...new Set(primaryValues)];
|
||||
|
||||
expect(uniquePrimary[0]).toBe('A');
|
||||
expect(uniquePrimary[1]).toBe('B');
|
||||
expect(uniquePrimary[2]).toBe('C');
|
||||
});
|
||||
|
||||
it('should handle null secondary dimension values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', null], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['C', null], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
expect(
|
||||
result.find(
|
||||
(r) =>
|
||||
r.groupByDimensionValues[0] === 'B' &&
|
||||
r.groupByDimensionValues[1] === null,
|
||||
),
|
||||
).toEqual({
|
||||
groupByDimensionValues: ['B', null],
|
||||
aggregateValue: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not fill missing secondary axis values', () => {
|
||||
const data = [
|
||||
{ groupByDimensionValues: ['A', 'X'], aggregateValue: 5 },
|
||||
{ groupByDimensionValues: ['A', 'Y'], aggregateValue: 3 },
|
||||
];
|
||||
|
||||
const result = fillSelectGapsTwoDimensional({
|
||||
data,
|
||||
selectOptions,
|
||||
});
|
||||
|
||||
const secondaryValues = result.map((r) => r.groupByDimensionValues[1]);
|
||||
const uniqueSecondary = [...new Set(secondaryValues)];
|
||||
|
||||
expect(uniqueSecondary).toHaveLength(2);
|
||||
expect(uniqueSecondary).toContain('X');
|
||||
expect(uniqueSecondary).toContain('Y');
|
||||
});
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { filterByRange } from 'src/modules/dashboard/chart-data/utils/filter-by-range.util';
|
||||
|
||||
describe('filterByRange', () => {
|
||||
describe('rangeMin filtering', () => {
|
||||
it('should filter out results below rangeMin', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
expect(filtered[1].aggregateValue).toBe(2500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMin', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1000);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMax filtering', () => {
|
||||
it('should filter out results above rangeMax', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(500);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include values equal to rangeMax', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 2000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
expect(filtered[1].aggregateValue).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined range filtering', () => {
|
||||
it('should keep only results within range', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].aggregateValue).toBe(1500);
|
||||
});
|
||||
|
||||
it('should include boundary values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1000 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['D'], aggregateValue: 2000 },
|
||||
{ groupByDimensionValues: ['E'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 1000, 2000);
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
expect(filtered[0].aggregateValue).toBe(1000);
|
||||
expect(filtered[1].aggregateValue).toBe(1500);
|
||||
expect(filtered[2].aggregateValue).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no filters', () => {
|
||||
it('should return all results when no range is specified', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
|
||||
it('should return all results when ranges are null', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, null, null);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
|
||||
it('should return all results when ranges are undefined', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 500 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 1500 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 2500 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, undefined, undefined);
|
||||
|
||||
expect(filtered).toEqual(results);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty results array', () => {
|
||||
const filtered = filterByRange([], 1000, 2000);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: 0 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 100 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, 0);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle negative values', () => {
|
||||
const results = [
|
||||
{ groupByDimensionValues: ['A'], aggregateValue: -100 },
|
||||
{ groupByDimensionValues: ['B'], aggregateValue: 0 },
|
||||
{ groupByDimensionValues: ['C'], aggregateValue: 100 },
|
||||
];
|
||||
|
||||
const filtered = filterByRange(results, -50, 50);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].aggregateValue).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import { filterTwoDimensionalDataByRange } from 'src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util';
|
||||
|
||||
describe('filterTwoDimensionalDataByRange', () => {
|
||||
describe('no filters', () => {
|
||||
it('should return all data when no range is specified', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return all data when ranges are null', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, null, null);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
|
||||
it('should return all data when ranges are undefined', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 200 },
|
||||
{ category: 'B', seriesA: 300, seriesB: 400 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(filtered).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMin filtering', () => {
|
||||
it('should filter out data with total below rangeMin', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
});
|
||||
|
||||
it('should include data with total equal to rangeMin', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 200, seriesB: 200 }, // total: 400
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 400);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeMax filtering', () => {
|
||||
it('should filter out data with total above rangeMax', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
600,
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
expect(filtered[1].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should include data with total equal to rangeMax', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
data,
|
||||
keys,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
expect(filtered[1].category).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined range filtering', () => {
|
||||
it('should keep only data within range', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'C', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should include boundary values', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100, seriesB: 100 }, // total: 200
|
||||
{ category: 'B', seriesA: 150, seriesB: 150 }, // total: 300
|
||||
{ category: 'C', seriesA: 300, seriesB: 200 }, // total: 500
|
||||
{ category: 'D', seriesA: 400, seriesB: 400 }, // total: 800
|
||||
{ category: 'E', seriesA: 500, seriesB: 500 }, // total: 1000
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 300, 800);
|
||||
|
||||
expect(filtered).toHaveLength(3);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
expect(filtered[1].category).toBe('C');
|
||||
expect(filtered[2].category).toBe('D');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty data array', () => {
|
||||
const filtered = filterTwoDimensionalDataByRange(
|
||||
[],
|
||||
['seriesA'],
|
||||
100,
|
||||
200,
|
||||
);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty keys array', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 100 },
|
||||
{ category: 'B', seriesA: 200 },
|
||||
];
|
||||
|
||||
// With empty keys, total is always 0
|
||||
const filtered = filterTwoDimensionalDataByRange(data, [], 0, 0);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should ignore non-numeric values when summing', () => {
|
||||
const data = [
|
||||
{
|
||||
category: 'A',
|
||||
seriesA: 100,
|
||||
seriesB: 'invalid' as unknown as number,
|
||||
},
|
||||
{ category: 'B', seriesA: 300, seriesB: 200 },
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
// category A total is 100 (invalid is treated as 0)
|
||||
// category B total is 500
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 200);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const data = [
|
||||
{ category: 'A', seriesA: 0, seriesB: 0 }, // total: 0
|
||||
{ category: 'B', seriesA: 0, seriesB: 100 }, // total: 100
|
||||
];
|
||||
const keys = ['seriesA', 'seriesB'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 0, 50);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('A');
|
||||
});
|
||||
|
||||
it('should handle single key', () => {
|
||||
const data = [
|
||||
{ category: 'A', value: 100 },
|
||||
{ category: 'B', value: 300 },
|
||||
{ category: 'C', value: 500 },
|
||||
];
|
||||
const keys = ['value'];
|
||||
|
||||
const filtered = filterTwoDimensionalDataByRange(data, keys, 200, 400);
|
||||
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].category).toBe('B');
|
||||
});
|
||||
});
|
||||
});
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { formatDimensionValue } from 'src/modules/dashboard/chart-data/utils/format-dimension-value.util';
|
||||
|
||||
const createMockFieldMetadata = (
|
||||
overrides: Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id: 'test-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
const userTimezone = 'Europe/Paris';
|
||||
const firstDayOfTheWeek = FirstDayOfTheWeek.MONDAY;
|
||||
|
||||
describe('formatDimensionValue', () => {
|
||||
describe('null and undefined values', () => {
|
||||
it('should return "Not Set" for null value', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const result = formatDimensionValue({
|
||||
value: null,
|
||||
fieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Not Set');
|
||||
});
|
||||
|
||||
it('should return "Not Set" for undefined value', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const result = formatDimensionValue({
|
||||
value: undefined,
|
||||
fieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Not Set');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SELECT field', () => {
|
||||
const selectFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.SELECT,
|
||||
options: [
|
||||
{ value: 'ACTIVE', label: 'Active', color: 'green', position: 0 },
|
||||
{ value: 'INACTIVE', label: 'Inactive', color: 'red', position: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
it('should return option label for matching value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'ACTIVE',
|
||||
fieldMetadata: selectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Active');
|
||||
});
|
||||
|
||||
it('should return value as string when option not found', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'UNKNOWN',
|
||||
fieldMetadata: selectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('UNKNOWN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MULTI_SELECT field', () => {
|
||||
const multiSelectFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.MULTI_SELECT,
|
||||
options: [
|
||||
{ value: 'TAG1', label: 'Tag One', color: 'blue', position: 0 },
|
||||
{ value: 'TAG2', label: 'Tag Two', color: 'green', position: 1 },
|
||||
{ value: 'TAG3', label: 'Tag Three', color: 'red', position: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
it('should return joined labels for array value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: ['TAG1', 'TAG2'],
|
||||
fieldMetadata: multiSelectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Tag One, Tag Two');
|
||||
});
|
||||
|
||||
it('should parse postgres array format', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '{TAG1,TAG2}',
|
||||
fieldMetadata: multiSelectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Tag One, Tag Two');
|
||||
});
|
||||
|
||||
it('should handle empty postgres array format', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '{}',
|
||||
fieldMetadata: multiSelectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle single value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'TAG1',
|
||||
fieldMetadata: multiSelectFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Tag One');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BOOLEAN field', () => {
|
||||
const booleanFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
});
|
||||
|
||||
it('should return "Yes" for true', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: true,
|
||||
fieldMetadata: booleanFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should return "No" for false', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: false,
|
||||
fieldMetadata: booleanFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('No');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DATE and DATE_TIME fields', () => {
|
||||
const dateFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const dateTimeFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
});
|
||||
|
||||
it('should return string value for DAY_OF_THE_WEEK granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'Monday',
|
||||
fieldMetadata: dateFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Monday');
|
||||
});
|
||||
|
||||
it('should return string value for MONTH_OF_THE_YEAR granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'January',
|
||||
fieldMetadata: dateFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('January');
|
||||
});
|
||||
|
||||
it('should return string value for QUARTER_OF_THE_YEAR granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'Q1',
|
||||
fieldMetadata: dateFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Q1');
|
||||
});
|
||||
|
||||
it('should format date for DAY granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '2024-01-15',
|
||||
fieldMetadata: dateFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should format datetime for MONTH granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '2024-01-15',
|
||||
fieldMetadata: dateTimeFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RELATION field', () => {
|
||||
const relationFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.RELATION,
|
||||
});
|
||||
|
||||
it('should return string value for relation', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'Company Name',
|
||||
fieldMetadata: relationFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Company Name');
|
||||
});
|
||||
|
||||
it('should return string value for relation with date granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '2024-01-15',
|
||||
fieldMetadata: relationFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('2024-01-15');
|
||||
});
|
||||
|
||||
it('should return string value for DAY_OF_THE_WEEK granularity', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'Monday',
|
||||
fieldMetadata: relationFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Monday');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NUMBER field', () => {
|
||||
const numberFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
it('should format number with short number format', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 1500,
|
||||
fieldMetadata: numberFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('1.5k');
|
||||
});
|
||||
|
||||
it('should handle string number value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '2000',
|
||||
fieldMetadata: numberFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('2k');
|
||||
});
|
||||
|
||||
it('should return string for NaN value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'not a number',
|
||||
fieldMetadata: numberFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('not a number');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 0,
|
||||
fieldMetadata: numberFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CURRENCY field', () => {
|
||||
const currencyFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.CURRENCY,
|
||||
});
|
||||
|
||||
it('should format currency amount value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 1500,
|
||||
fieldMetadata: currencyFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('1.5k');
|
||||
});
|
||||
|
||||
it('should return currency code as-is for currencyCode subfield', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'USD',
|
||||
fieldMetadata: currencyFieldMetadata,
|
||||
subFieldName: 'currencyCode',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('USD');
|
||||
});
|
||||
|
||||
it('should return "Not Set" for empty currency code', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: '',
|
||||
fieldMetadata: currencyFieldMetadata,
|
||||
subFieldName: 'currencyCode',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Not Set');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TEXT and other fields', () => {
|
||||
const textFieldMetadata = createMockFieldMetadata({
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
it('should return string value', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 'Hello World',
|
||||
fieldMetadata: textFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should convert number to string', () => {
|
||||
const result = formatDimensionValue({
|
||||
value: 123,
|
||||
fieldMetadata: textFieldMetadata,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
expect(result).toBe('123');
|
||||
});
|
||||
});
|
||||
});
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant';
|
||||
import { generateDateGroupsInRange } from 'src/modules/dashboard/chart-data/utils/generate-date-groups-in-range.util';
|
||||
|
||||
describe('generateDateGroupsInRange', () => {
|
||||
describe('DAY granularity', () => {
|
||||
it('should generate daily dates in range', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-01-05'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(5);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2024-01-01');
|
||||
expect(result.dates[4].toString()).toBe('2024-01-05');
|
||||
});
|
||||
|
||||
it('should return single date when start equals end', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(1);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2024-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WEEK granularity', () => {
|
||||
it('should generate weekly dates in range', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-01-29'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(5);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2024-01-01');
|
||||
expect(result.dates[1].toString()).toBe('2024-01-08');
|
||||
expect(result.dates[2].toString()).toBe('2024-01-15');
|
||||
expect(result.dates[3].toString()).toBe('2024-01-22');
|
||||
expect(result.dates[4].toString()).toBe('2024-01-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MONTH granularity', () => {
|
||||
it('should generate monthly dates in range', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-04-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(4);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2024-01-01');
|
||||
expect(result.dates[1].toString()).toBe('2024-02-01');
|
||||
expect(result.dates[2].toString()).toBe('2024-03-01');
|
||||
expect(result.dates[3].toString()).toBe('2024-04-01');
|
||||
});
|
||||
|
||||
it('should handle year boundaries', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2023-11-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-02-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(4);
|
||||
expect(result.dates[0].toString()).toBe('2023-11-01');
|
||||
expect(result.dates[1].toString()).toBe('2023-12-01');
|
||||
expect(result.dates[2].toString()).toBe('2024-01-01');
|
||||
expect(result.dates[3].toString()).toBe('2024-02-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QUARTER granularity', () => {
|
||||
it('should generate quarterly dates in range', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-10-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(4);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2024-01-01');
|
||||
expect(result.dates[1].toString()).toBe('2024-04-01');
|
||||
expect(result.dates[2].toString()).toBe('2024-07-01');
|
||||
expect(result.dates[3].toString()).toBe('2024-10-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('YEAR granularity', () => {
|
||||
it('should generate yearly dates in range', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: Temporal.PlainDate.from('2020-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.YEAR,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(5);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.dates[0].toString()).toBe('2020-01-01');
|
||||
expect(result.dates[1].toString()).toBe('2021-01-01');
|
||||
expect(result.dates[2].toString()).toBe('2022-01-01');
|
||||
expect(result.dates[3].toString()).toBe('2023-01-01');
|
||||
expect(result.dates[4].toString()).toBe('2024-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncation', () => {
|
||||
it('should truncate when exceeding maximum number of bars', () => {
|
||||
const startDate = Temporal.PlainDate.from('2020-01-01');
|
||||
const endDate = startDate.add({
|
||||
days: BAR_CHART_MAXIMUM_NUMBER_OF_BARS + 50,
|
||||
});
|
||||
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate,
|
||||
endDate,
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(BAR_CHART_MAXIMUM_NUMBER_OF_BARS);
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it('should not truncate when exactly at maximum', () => {
|
||||
const startDate = Temporal.PlainDate.from('2020-01-01');
|
||||
const endDate = startDate.add({
|
||||
days: BAR_CHART_MAXIMUM_NUMBER_OF_BARS - 1,
|
||||
});
|
||||
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate,
|
||||
endDate,
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(BAR_CHART_MAXIMUM_NUMBER_OF_BARS);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-field-order-by.util';
|
||||
|
||||
const createMockFieldMetadata = (
|
||||
overrides: Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id: 'test-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
describe('getFieldOrderBy', () => {
|
||||
describe('composite fields', () => {
|
||||
it('should return nested object for FULL_NAME field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
'firstName',
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: {
|
||||
firstName: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nested object for ADDRESS field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
'addressCity',
|
||||
undefined,
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
address: {
|
||||
addressCity: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for composite field without subFieldName', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
),
|
||||
).toThrow(
|
||||
'Group by subFieldName is required for composite fields (field: name)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date fields', () => {
|
||||
it('should return date order by with default granularity', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.AscNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return date order by with custom granularity', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.AscNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle DATE_TIME field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedAt: {
|
||||
orderBy: OrderByDirection.DescNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.YEAR,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('relation fields', () => {
|
||||
it('should return Id suffix for relation field without subFieldName', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-id',
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
companyId: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nested object for relation field with subFieldName', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-id',
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
'name',
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
name: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('scalar fields', () => {
|
||||
it('should return simple order by for TEXT field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return simple order by for SELECT field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'priority',
|
||||
type: FieldMetadataType.SELECT,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
priority: OrderByDirection.DescNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return simple order by for NUMBER field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'quantity',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
quantity: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return simple order by for BOOLEAN field', () => {
|
||||
const fieldMetadata = createMockFieldMetadata({
|
||||
name: 'isActive',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
});
|
||||
|
||||
const result = getFieldOrderBy(
|
||||
fieldMetadata,
|
||||
null,
|
||||
undefined,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
isActive: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { getGroupByOrderBy } from 'src/modules/dashboard/chart-data/utils/get-group-by-order-by.util';
|
||||
|
||||
const createMockFieldMetadata = (
|
||||
overrides: Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id: 'test-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
describe('getGroupByOrderBy', () => {
|
||||
const groupByFieldMetadata = createMockFieldMetadata({
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const aggregateFieldMetadata = createMockFieldMetadata({
|
||||
name: 'amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
});
|
||||
|
||||
describe('FIELD_ASC', () => {
|
||||
it('should return field order by ascending', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_ASC,
|
||||
groupByFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle date field with granularity', () => {
|
||||
const dateFieldMetadata = createMockFieldMetadata({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
});
|
||||
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_ASC,
|
||||
groupByFieldMetadata: dateFieldMetadata,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.AscNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_DESC', () => {
|
||||
it('should return field order by descending', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_DESC,
|
||||
groupByFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
status: OrderByDirection.DescNullsLast,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_ASC', () => {
|
||||
it('should return aggregate order by ascending', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.VALUE_ASC,
|
||||
groupByFieldMetadata,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
aggregateFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
aggregate: {
|
||||
sumAmount: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when aggregate operation is missing', () => {
|
||||
expect(() =>
|
||||
getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.VALUE_ASC,
|
||||
groupByFieldMetadata,
|
||||
aggregateFieldMetadata,
|
||||
}),
|
||||
).toThrow(
|
||||
'Aggregate operation or field metadata not found (field: status)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when aggregate field metadata is missing', () => {
|
||||
expect(() =>
|
||||
getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.VALUE_ASC,
|
||||
groupByFieldMetadata,
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
}),
|
||||
).toThrow(
|
||||
'Aggregate operation or field metadata not found (field: status)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_DESC', () => {
|
||||
it('should return aggregate order by descending', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.VALUE_DESC,
|
||||
groupByFieldMetadata,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
aggregateFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
aggregate: {
|
||||
totalCount: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different aggregate operations', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.VALUE_DESC,
|
||||
groupByFieldMetadata,
|
||||
aggregateOperation: AggregateOperations.AVG,
|
||||
aggregateFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
aggregate: {
|
||||
avgAmount: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_ASC', () => {
|
||||
it('should return undefined', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
groupByFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_DESC', () => {
|
||||
it('should return undefined', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_POSITION_DESC,
|
||||
groupByFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MANUAL', () => {
|
||||
it('should return undefined', () => {
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.MANUAL,
|
||||
groupByFieldMetadata,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with subFieldName', () => {
|
||||
it('should handle composite field with subFieldName', () => {
|
||||
const compositeFieldMetadata = createMockFieldMetadata({
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
});
|
||||
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_ASC,
|
||||
groupByFieldMetadata: compositeFieldMetadata,
|
||||
groupBySubFieldName: 'firstName',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: {
|
||||
firstName: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle relation field with subFieldName', () => {
|
||||
const relationFieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-id',
|
||||
});
|
||||
|
||||
const result = getGroupByOrderBy({
|
||||
graphOrderBy: GraphOrderBy.FIELD_DESC,
|
||||
groupByFieldMetadata: relationFieldMetadata,
|
||||
groupBySubFieldName: 'name',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
name: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { getRelationFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util';
|
||||
|
||||
const createMockFieldMetadata = (
|
||||
overrides: Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id: 'test-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.RELATION,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
describe('getRelationFieldOrderBy', () => {
|
||||
const relationFieldMetadata = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'target-id',
|
||||
});
|
||||
|
||||
describe('without subFieldName', () => {
|
||||
it('should return Id suffix for relation field', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
null,
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
companyId: OrderByDirection.AscNullsLast,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Id suffix for undefined subFieldName', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
undefined,
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
companyId: OrderByDirection.DescNullsLast,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with simple subFieldName', () => {
|
||||
it('should return nested object for simple subfield', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'name',
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
name: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle descending direction', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'name',
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
name: OrderByDirection.DescNullsLast,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with composite subFieldName', () => {
|
||||
it('should return deeply nested object for composite subfield', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'address.addressCity',
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
address: {
|
||||
addressCity: OrderByDirection.AscNullsLast,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with date granularity', () => {
|
||||
it('should return date order by with granularity', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'createdAt',
|
||||
OrderByDirection.AscNullsLast,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.AscNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default granularity when isNestedDateField is true but granularity not provided', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'createdAt',
|
||||
OrderByDirection.AscNullsLast,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.AscNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return date order by when dateGranularity is provided', () => {
|
||||
const result = getRelationFieldOrderBy(
|
||||
relationFieldMetadata,
|
||||
'createdAt',
|
||||
OrderByDirection.DescNullsLast,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: {
|
||||
orderBy: OrderByDirection.DescNullsLast,
|
||||
granularity: ObjectRecordGroupByDateGranularity.YEAR,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { isRelationNestedFieldDateKind } from 'src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util';
|
||||
|
||||
const createMockFieldMetadata = (
|
||||
overrides: Partial<FlatFieldMetadata>,
|
||||
): FlatFieldMetadata =>
|
||||
({
|
||||
id: 'test-field-id',
|
||||
name: 'testField',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'test-universal-id',
|
||||
...overrides,
|
||||
}) as FlatFieldMetadata;
|
||||
|
||||
const createMockObjectMetadata = (
|
||||
overrides: Partial<FlatObjectMetadata>,
|
||||
): FlatObjectMetadata =>
|
||||
({
|
||||
id: 'test-object-id',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
fieldMetadataIds: [],
|
||||
universalIdentifier: 'test-object-universal-id',
|
||||
...overrides,
|
||||
}) as FlatObjectMetadata;
|
||||
|
||||
describe('isRelationNestedFieldDateKind', () => {
|
||||
const companyObjectId = 'company-object-id';
|
||||
const createdAtFieldId = 'created-at-field-id';
|
||||
const nameFieldId = 'name-field-id';
|
||||
|
||||
const createdAtField = createMockFieldMetadata({
|
||||
id: createdAtFieldId,
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: 'created-at-universal-id',
|
||||
});
|
||||
|
||||
const nameField = createMockFieldMetadata({
|
||||
id: nameFieldId,
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'name-universal-id',
|
||||
});
|
||||
|
||||
const companyObject = createMockObjectMetadata({
|
||||
id: companyObjectId,
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
fieldMetadataIds: [createdAtFieldId, nameFieldId],
|
||||
});
|
||||
|
||||
const relationField = createMockFieldMetadata({
|
||||
id: 'relation-field-id',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: companyObjectId,
|
||||
universalIdentifier: 'relation-universal-id',
|
||||
});
|
||||
|
||||
const flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata> = {
|
||||
byId: {
|
||||
[companyObjectId]: companyObject,
|
||||
},
|
||||
idByUniversalIdentifier: {},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
};
|
||||
|
||||
const flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
|
||||
byId: {
|
||||
[createdAtFieldId]: createdAtField,
|
||||
[nameFieldId]: nameField,
|
||||
},
|
||||
idByUniversalIdentifier: {},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
};
|
||||
|
||||
it('should return true for a relation subfield that is a date type', () => {
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationField,
|
||||
relationNestedFieldName: 'createdAt',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when the nested subfield is not a date type', () => {
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationField,
|
||||
relationNestedFieldName: 'name',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when relationNestedFieldName is undefined', () => {
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationField,
|
||||
relationNestedFieldName: undefined,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-relation fields', () => {
|
||||
const nonRelationField = createMockFieldMetadata({
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
});
|
||||
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: nonRelationField,
|
||||
relationNestedFieldName: 'createdAt',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when target object is not found', () => {
|
||||
const relationFieldWithMissingTarget = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'non-existent-object-id',
|
||||
});
|
||||
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationFieldWithMissingTarget,
|
||||
relationNestedFieldName: 'createdAt',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when nested field is not found', () => {
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationField,
|
||||
relationNestedFieldName: 'nonExistentField',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle composite nested field names', () => {
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationField,
|
||||
relationNestedFieldName: 'createdAt.subField',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for DATE type field', () => {
|
||||
const dateFieldId = 'date-field-id';
|
||||
const dateField = createMockFieldMetadata({
|
||||
id: dateFieldId,
|
||||
name: 'birthDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
universalIdentifier: 'date-universal-id',
|
||||
});
|
||||
|
||||
const objectWithDateField = createMockObjectMetadata({
|
||||
id: 'object-with-date-id',
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
fieldMetadataIds: [dateFieldId],
|
||||
});
|
||||
|
||||
const personRelationField = createMockFieldMetadata({
|
||||
name: 'person',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: 'object-with-date-id',
|
||||
});
|
||||
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: personRelationField,
|
||||
relationNestedFieldName: 'birthDate',
|
||||
flatObjectMetadataMaps: {
|
||||
byId: { 'object-with-date-id': objectWithDateField },
|
||||
idByUniversalIdentifier: {},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byId: { [dateFieldId]: dateField },
|
||||
idByUniversalIdentifier: {},
|
||||
universalIdentifiersByApplicationId: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when relationTargetObjectMetadataId is undefined', () => {
|
||||
const relationFieldWithoutTarget = createMockFieldMetadata({
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
relationTargetObjectMetadataId: undefined,
|
||||
});
|
||||
|
||||
const result = isRelationNestedFieldDateKind({
|
||||
relationFieldMetadata: relationFieldWithoutTarget,
|
||||
relationNestedFieldName: 'createdAt',
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { mapOrderByToDirection } from 'src/modules/dashboard/chart-data/utils/map-order-by-to-direction.util';
|
||||
|
||||
describe('mapOrderByToDirection', () => {
|
||||
describe('FIELD_ASC', () => {
|
||||
it('should return AscNullsLast', () => {
|
||||
const result = mapOrderByToDirection(GraphOrderBy.FIELD_ASC);
|
||||
|
||||
expect(result).toBe(OrderByDirection.AscNullsLast);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_DESC', () => {
|
||||
it('should return DescNullsLast', () => {
|
||||
const result = mapOrderByToDirection(GraphOrderBy.FIELD_DESC);
|
||||
|
||||
expect(result).toBe(OrderByDirection.DescNullsLast);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_ASC', () => {
|
||||
it('should return AscNullsLast', () => {
|
||||
const result = mapOrderByToDirection(GraphOrderBy.VALUE_ASC);
|
||||
|
||||
expect(result).toBe(OrderByDirection.AscNullsLast);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_DESC', () => {
|
||||
it('should return DescNullsLast', () => {
|
||||
const result = mapOrderByToDirection(GraphOrderBy.VALUE_DESC);
|
||||
|
||||
expect(result).toBe(OrderByDirection.DescNullsLast);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consistency', () => {
|
||||
it('should map all ASC orders to AscNullsLast', () => {
|
||||
expect(mapOrderByToDirection(GraphOrderBy.FIELD_ASC)).toBe(
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
expect(mapOrderByToDirection(GraphOrderBy.VALUE_ASC)).toBe(
|
||||
OrderByDirection.AscNullsLast,
|
||||
);
|
||||
});
|
||||
|
||||
it('should map all DESC orders to DescNullsLast', () => {
|
||||
expect(mapOrderByToDirection(GraphOrderBy.FIELD_DESC)).toBe(
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
expect(mapOrderByToDirection(GraphOrderBy.VALUE_DESC)).toBe(
|
||||
OrderByDirection.DescNullsLast,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { sortByManualOrder } from 'src/modules/dashboard/chart-data/utils/sort-by-manual-order.util';
|
||||
|
||||
describe('sortByManualOrder', () => {
|
||||
type TestItem = { label: string };
|
||||
|
||||
const testData: TestItem[] = [
|
||||
{ label: 'Beta' },
|
||||
{ label: 'Alpha' },
|
||||
{ label: 'Gamma' },
|
||||
];
|
||||
|
||||
const getRawValue = (item: TestItem) => item.label;
|
||||
|
||||
it('should sort items according to manual order', () => {
|
||||
const result = sortByManualOrder({
|
||||
items: testData,
|
||||
manualSortOrder: ['Gamma', 'Alpha', 'Beta'],
|
||||
getRawValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Gamma',
|
||||
'Alpha',
|
||||
'Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return items unchanged when manual order is empty', () => {
|
||||
const result = sortByManualOrder({
|
||||
items: testData,
|
||||
manualSortOrder: [],
|
||||
getRawValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should put items not in manual order at the end', () => {
|
||||
const result = sortByManualOrder({
|
||||
items: testData,
|
||||
manualSortOrder: ['Alpha', 'Gamma'],
|
||||
getRawValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Alpha',
|
||||
'Gamma',
|
||||
'Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle items with null raw values', () => {
|
||||
const dataWithNull = [
|
||||
{ label: 'Alpha' },
|
||||
{ label: null as unknown as string },
|
||||
{ label: 'Beta' },
|
||||
];
|
||||
|
||||
const result = sortByManualOrder({
|
||||
items: dataWithNull,
|
||||
manualSortOrder: ['Beta', 'Alpha'],
|
||||
getRawValue: (item) => item.label,
|
||||
});
|
||||
|
||||
expect(result[0].label).toBe('Beta');
|
||||
expect(result[1].label).toBe('Alpha');
|
||||
expect(result[2].label).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty items array', () => {
|
||||
const result = sortByManualOrder({
|
||||
items: [],
|
||||
manualSortOrder: ['Alpha', 'Beta'],
|
||||
getRawValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should maintain stability for items with equal positions', () => {
|
||||
const result = sortByManualOrder({
|
||||
items: testData,
|
||||
manualSortOrder: ['Delta'],
|
||||
getRawValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { sortBySelectOptionPosition } from 'src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util';
|
||||
|
||||
describe('sortBySelectOptionPosition', () => {
|
||||
type TestItem = { label: string };
|
||||
|
||||
const testItems: TestItem[] = [
|
||||
{ label: 'Option B' },
|
||||
{ label: 'Option C' },
|
||||
{ label: 'Option A' },
|
||||
];
|
||||
|
||||
const options = [
|
||||
{ value: 'opt-a', position: 0 },
|
||||
{ value: 'opt-b', position: 1 },
|
||||
{ value: 'opt-c', position: 2 },
|
||||
];
|
||||
|
||||
const formattedToRawLookup = new Map([
|
||||
['Option A', 'opt-a'],
|
||||
['Option B', 'opt-b'],
|
||||
['Option C', 'opt-c'],
|
||||
]);
|
||||
|
||||
const getFormattedValue = (item: TestItem) => item.label;
|
||||
|
||||
it('should sort items by select option position in ascending order', () => {
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: testItems,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Option A',
|
||||
'Option B',
|
||||
'Option C',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort items by select option position in descending order', () => {
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: testItems,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'DESC',
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Option C',
|
||||
'Option B',
|
||||
'Option A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should place items not in options at the end when sorting ascending', () => {
|
||||
const itemsWithUnknown: TestItem[] = [
|
||||
{ label: 'Unknown' },
|
||||
{ label: 'Option A' },
|
||||
{ label: 'Option B' },
|
||||
];
|
||||
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: itemsWithUnknown,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Option A',
|
||||
'Option B',
|
||||
'Unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should place items not in options at the beginning when sorting descending', () => {
|
||||
const itemsWithUnknown: TestItem[] = [
|
||||
{ label: 'Unknown' },
|
||||
{ label: 'Option A' },
|
||||
{ label: 'Option B' },
|
||||
];
|
||||
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: itemsWithUnknown,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'DESC',
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Unknown',
|
||||
'Option B',
|
||||
'Option A',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty items array', () => {
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: [],
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle items without raw value in lookup', () => {
|
||||
const itemsWithMissingLookup: TestItem[] = [
|
||||
{ label: 'Not In Lookup' },
|
||||
{ label: 'Option A' },
|
||||
];
|
||||
|
||||
const result = sortBySelectOptionPosition({
|
||||
items: itemsWithMissingLookup,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: 'ASC',
|
||||
});
|
||||
|
||||
expect(result[0].label).toBe('Option A');
|
||||
expect(result[1].label).toBe('Not In Lookup');
|
||||
});
|
||||
});
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
|
||||
|
||||
describe('sortChartDataIfNeeded', () => {
|
||||
type TestItem = { label: string; value: number };
|
||||
|
||||
const testData: TestItem[] = [
|
||||
{ label: 'Paris', value: 15 },
|
||||
{ label: 'London', value: 22 },
|
||||
{ label: 'Berlin', value: 8 },
|
||||
];
|
||||
|
||||
const formattedToRawLookup = new Map<string, string>([
|
||||
['Paris', 'PARIS'],
|
||||
['London', 'LONDON'],
|
||||
['Berlin', 'BERLIN'],
|
||||
]);
|
||||
|
||||
const getFieldValue = (item: TestItem) => item.label;
|
||||
const getNumericValue = (item: TestItem) => item.value;
|
||||
|
||||
describe('VALUE_ASC sorting', () => {
|
||||
it('should sort by numeric values in ascending order', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.VALUE_ASC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Berlin',
|
||||
'Paris',
|
||||
'London',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_DESC sorting', () => {
|
||||
it('should sort by numeric values in descending order', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'London',
|
||||
'Paris',
|
||||
'Berlin',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MANUAL sorting', () => {
|
||||
it('should sort by manual order when provided', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.MANUAL,
|
||||
manualSortOrder: ['BERLIN', 'PARIS', 'LONDON'],
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Berlin',
|
||||
'Paris',
|
||||
'London',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return data unchanged when manual order is undefined', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.MANUAL,
|
||||
manualSortOrder: undefined,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_ASC sorting', () => {
|
||||
it('should sort by select option position in ascending order', () => {
|
||||
const selectFieldOptions = [
|
||||
{ value: 'LONDON', position: 0 },
|
||||
{ value: 'PARIS', position: 1 },
|
||||
{ value: 'BERLIN', position: 2 },
|
||||
];
|
||||
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
selectFieldOptions,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'London',
|
||||
'Paris',
|
||||
'Berlin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return data unchanged when no select options provided', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
selectFieldOptions: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_DESC sorting', () => {
|
||||
it('should sort by select option position in descending order', () => {
|
||||
const selectFieldOptions = [
|
||||
{ value: 'LONDON', position: 0 },
|
||||
{ value: 'PARIS', position: 1 },
|
||||
{ value: 'BERLIN', position: 2 },
|
||||
];
|
||||
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_DESC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
selectFieldOptions,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Berlin',
|
||||
'Paris',
|
||||
'London',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should return data unchanged when orderBy is undefined', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: undefined,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should sort by field value ascending for FIELD_ASC', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Berlin',
|
||||
'London',
|
||||
'Paris',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should sort by field value descending for FIELD_DESC', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: testData,
|
||||
orderBy: GraphOrderBy.FIELD_DESC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Paris',
|
||||
'London',
|
||||
'Berlin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty data array', () => {
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: [],
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single item', () => {
|
||||
const singleItem = [{ label: 'Paris', value: 10 }];
|
||||
|
||||
const result = sortChartDataIfNeeded({
|
||||
data: singleItem,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(singleItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
|
||||
|
||||
describe('sortSecondaryAxisData', () => {
|
||||
type TestItem = { label: string; value: number };
|
||||
|
||||
const testItems: TestItem[] = [
|
||||
{ label: 'Beta', value: 20 },
|
||||
{ label: 'Alpha', value: 10 },
|
||||
{ label: 'Gamma', value: 30 },
|
||||
];
|
||||
|
||||
const getFormattedValue = (item: TestItem) => item.label;
|
||||
const getNumericValue = (item: TestItem) => item.value;
|
||||
|
||||
describe('FIELD_ASC sorting', () => {
|
||||
it('should sort by field value ascending', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Alpha',
|
||||
'Beta',
|
||||
'Gamma',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_DESC sorting', () => {
|
||||
it('should sort by field value descending', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_DESC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Gamma',
|
||||
'Beta',
|
||||
'Alpha',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_ASC sorting', () => {
|
||||
it('should sort by numeric value ascending', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.VALUE_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toEqual([10, 20, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_DESC sorting', () => {
|
||||
it('should sort by numeric value descending', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toEqual([30, 20, 10]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_ASC sorting', () => {
|
||||
it('should sort by select option position ascending', () => {
|
||||
const formattedToRawLookup = new Map([
|
||||
['Alpha', 'opt-a'],
|
||||
['Beta', 'opt-b'],
|
||||
['Gamma', 'opt-c'],
|
||||
]);
|
||||
|
||||
const selectFieldOptions = [
|
||||
{ value: 'opt-c', position: 0, label: 'Gamma' },
|
||||
{ value: 'opt-a', position: 1, label: 'Alpha' },
|
||||
{ value: 'opt-b', position: 2, label: 'Beta' },
|
||||
];
|
||||
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
formattedToRawLookup,
|
||||
selectFieldOptions,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Gamma',
|
||||
'Alpha',
|
||||
'Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return items unchanged when selectFieldOptions is undefined', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
|
||||
it('should return items unchanged when selectFieldOptions is empty', () => {
|
||||
const formattedToRawLookup = new Map([
|
||||
['Alpha', 'opt-a'],
|
||||
['Beta', 'opt-b'],
|
||||
['Gamma', 'opt-c'],
|
||||
]);
|
||||
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
formattedToRawLookup,
|
||||
selectFieldOptions: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_POSITION_DESC sorting', () => {
|
||||
it('should sort by select option position descending', () => {
|
||||
const formattedToRawLookup = new Map([
|
||||
['Alpha', 'opt-a'],
|
||||
['Beta', 'opt-b'],
|
||||
['Gamma', 'opt-c'],
|
||||
]);
|
||||
|
||||
const selectFieldOptions = [
|
||||
{ value: 'opt-a', position: 0, label: 'Alpha' },
|
||||
{ value: 'opt-b', position: 1, label: 'Beta' },
|
||||
{ value: 'opt-c', position: 2, label: 'Gamma' },
|
||||
];
|
||||
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_POSITION_DESC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
formattedToRawLookup,
|
||||
selectFieldOptions,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Gamma',
|
||||
'Beta',
|
||||
'Alpha',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MANUAL sorting', () => {
|
||||
it('should sort by manual order', () => {
|
||||
const formattedToRawLookup = new Map([
|
||||
['Alpha', 'ALPHA'],
|
||||
['Beta', 'BETA'],
|
||||
['Gamma', 'GAMMA'],
|
||||
]);
|
||||
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.MANUAL,
|
||||
manualSortOrder: ['GAMMA', 'ALPHA', 'BETA'],
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
formattedToRawLookup,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.label)).toEqual([
|
||||
'Gamma',
|
||||
'Alpha',
|
||||
'Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return items unchanged when manualSortOrder is undefined', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.MANUAL,
|
||||
manualSortOrder: undefined,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
|
||||
it('should return items unchanged when manualSortOrder is null', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.MANUAL,
|
||||
manualSortOrder: null,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return items unchanged when orderBy is undefined', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: undefined,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
|
||||
it('should return items unchanged when orderBy is null', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: null,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(testItems);
|
||||
});
|
||||
|
||||
it('should handle empty items array', () => {
|
||||
const result = sortSecondaryAxisData({
|
||||
items: [],
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single item', () => {
|
||||
const singleItem = [{ label: 'Only', value: 1 }];
|
||||
|
||||
const result = sortSecondaryAxisData({
|
||||
items: singleItem,
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(result).toEqual(singleItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immutability', () => {
|
||||
it('should not mutate the original items array', () => {
|
||||
const originalItems = [...testItems];
|
||||
|
||||
sortSecondaryAxisData({
|
||||
items: testItems,
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
});
|
||||
|
||||
expect(testItems).toEqual(originalItems);
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import {
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataSelectKind,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import {
|
||||
fillDateGaps,
|
||||
fillDateGapsTwoDimensional,
|
||||
} from 'src/modules/dashboard/chart-data/utils/fill-date-gaps.util';
|
||||
import {
|
||||
fillSelectGaps,
|
||||
fillSelectGapsTwoDimensional,
|
||||
} from 'src/modules/dashboard/chart-data/utils/fill-select-gaps.util';
|
||||
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
|
||||
|
||||
type ApplyGapFillingParams = {
|
||||
data: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity | null | undefined;
|
||||
omitNullValues: boolean;
|
||||
isDescOrder: boolean;
|
||||
isTwoDimensional: boolean;
|
||||
};
|
||||
|
||||
type ApplyGapFillingResult = {
|
||||
data: GroupByRawResult[];
|
||||
wasTruncated: boolean;
|
||||
};
|
||||
|
||||
export const applyGapFilling = ({
|
||||
data,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity,
|
||||
omitNullValues,
|
||||
isDescOrder,
|
||||
isTwoDimensional,
|
||||
}: ApplyGapFillingParams): ApplyGapFillingResult => {
|
||||
if (omitNullValues) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
let currentData = data;
|
||||
let wasTruncated = false;
|
||||
|
||||
const isPrimaryFieldDate = isFieldMetadataDateKind(
|
||||
primaryAxisGroupByField.type,
|
||||
);
|
||||
|
||||
if (isPrimaryFieldDate) {
|
||||
const fillDateGapsFn = isTwoDimensional
|
||||
? fillDateGapsTwoDimensional
|
||||
: fillDateGaps;
|
||||
|
||||
const dateResult = fillDateGapsFn({
|
||||
data: currentData,
|
||||
dateGranularity,
|
||||
isDescOrder,
|
||||
});
|
||||
|
||||
currentData = dateResult.data;
|
||||
wasTruncated = dateResult.wasTruncated;
|
||||
}
|
||||
|
||||
const isPrimaryFieldSelect = isFieldMetadataSelectKind(
|
||||
primaryAxisGroupByField.type,
|
||||
);
|
||||
|
||||
if (isPrimaryFieldSelect) {
|
||||
const selectOptions = getSelectOptions(primaryAxisGroupByField);
|
||||
|
||||
const fillSelectGapsFn = isTwoDimensional
|
||||
? fillSelectGapsTwoDimensional
|
||||
: fillSelectGaps;
|
||||
|
||||
currentData = fillSelectGapsFn({
|
||||
data: currentData,
|
||||
selectOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return { data: currentData, wasTruncated };
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
|
||||
type BuildAggregateFieldKeyParams = {
|
||||
aggregateOperation: AggregateOperations;
|
||||
aggregateFieldMetadata: FlatFieldMetadata;
|
||||
};
|
||||
|
||||
export const buildAggregateFieldKey = ({
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata,
|
||||
}: BuildAggregateFieldKeyParams): string => {
|
||||
const fieldName = aggregateFieldMetadata.name;
|
||||
const fieldType = aggregateFieldMetadata.type;
|
||||
|
||||
switch (aggregateOperation) {
|
||||
case AggregateOperations.COUNT:
|
||||
return 'totalCount';
|
||||
|
||||
case AggregateOperations.COUNT_UNIQUE_VALUES:
|
||||
return `countUniqueValues${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.COUNT_EMPTY:
|
||||
return `countEmpty${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.COUNT_NOT_EMPTY:
|
||||
return `countNotEmpty${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.PERCENTAGE_EMPTY:
|
||||
return `percentageEmpty${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.PERCENTAGE_NOT_EMPTY:
|
||||
return `percentageNotEmpty${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.COUNT_TRUE:
|
||||
return `countTrue${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.COUNT_FALSE:
|
||||
return `countFalse${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.MIN:
|
||||
if (fieldType === FieldMetadataType.CURRENCY) {
|
||||
return `min${capitalize(fieldName)}AmountMicros`;
|
||||
}
|
||||
|
||||
return `min${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.MAX:
|
||||
if (fieldType === FieldMetadataType.CURRENCY) {
|
||||
return `max${capitalize(fieldName)}AmountMicros`;
|
||||
}
|
||||
|
||||
return `max${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.AVG:
|
||||
if (fieldType === FieldMetadataType.CURRENCY) {
|
||||
return `avg${capitalize(fieldName)}AmountMicros`;
|
||||
}
|
||||
|
||||
return `avg${capitalize(fieldName)}`;
|
||||
|
||||
case AggregateOperations.SUM:
|
||||
if (fieldType === FieldMetadataType.CURRENCY) {
|
||||
return `sum${capitalize(fieldName)}AmountMicros`;
|
||||
}
|
||||
|
||||
return `sum${capitalize(fieldName)}`;
|
||||
}
|
||||
};
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
CalendarStartDay,
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE,
|
||||
} from 'twenty-shared/constants';
|
||||
import {
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
|
||||
|
||||
export type GroupByFieldObject = Record<
|
||||
string,
|
||||
boolean | Record<string, boolean | string | Record<string, boolean | string>>
|
||||
>;
|
||||
|
||||
type BuildDateGroupByObjectParams = {
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
firstDayOfTheWeek?: CalendarStartDay | null;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
const buildDateGroupByObject = ({
|
||||
dateGranularity,
|
||||
firstDayOfTheWeek,
|
||||
timeZone,
|
||||
}: BuildDateGroupByObjectParams): Record<string, string> => {
|
||||
const usedDateGranularity = dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY;
|
||||
|
||||
const shouldHaveTimeZone =
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
|
||||
usedDateGranularity,
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {
|
||||
granularity: usedDateGranularity,
|
||||
};
|
||||
|
||||
if (shouldHaveTimeZone) {
|
||||
if (!isDefined(timeZone)) {
|
||||
throw new Error(`Date group by should have a time zone.`);
|
||||
}
|
||||
result.timeZone = timeZone;
|
||||
}
|
||||
|
||||
if (
|
||||
usedDateGranularity === ObjectRecordGroupByDateGranularity.WEEK &&
|
||||
isDefined(firstDayOfTheWeek) &&
|
||||
firstDayOfTheWeek !== CalendarStartDay.SYSTEM
|
||||
) {
|
||||
const weekStartDay = convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
|
||||
firstDayOfTheWeek,
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
|
||||
result.weekStartDay = weekStartDay;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export type BuildGroupByFieldObjectParams = {
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
subFieldName?: string | null;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
firstDayOfTheWeek?: CalendarStartDay | null;
|
||||
isNestedDateField?: boolean;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export const buildGroupByFieldObject = ({
|
||||
fieldMetadata,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField,
|
||||
timeZone,
|
||||
}: BuildGroupByFieldObjectParams): GroupByFieldObject => {
|
||||
const isRelation = isMorphOrRelationFlatFieldMetadata(fieldMetadata);
|
||||
const isComposite = isCompositeFieldMetadataType(fieldMetadata.type);
|
||||
const isDateField = isFieldMetadataDateKind(fieldMetadata.type);
|
||||
|
||||
if (isRelation) {
|
||||
if (!isDefined(subFieldName)) {
|
||||
return { [`${fieldMetadata.name}Id`]: true };
|
||||
}
|
||||
|
||||
const parts = subFieldName.split('.');
|
||||
const nestedFieldName = parts[0];
|
||||
const nestedSubFieldName = parts[1];
|
||||
|
||||
if (isNestedDateField === true) {
|
||||
const dateGroupByObject = buildDateGroupByObject({
|
||||
dateGranularity,
|
||||
firstDayOfTheWeek,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
return {
|
||||
[fieldMetadata.name]: {
|
||||
[nestedFieldName]: dateGroupByObject,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isDefined(nestedSubFieldName)) {
|
||||
return {
|
||||
[fieldMetadata.name]: {
|
||||
[nestedFieldName]: {
|
||||
[nestedSubFieldName]: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
[fieldMetadata.name]: {
|
||||
[nestedFieldName]: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isComposite) {
|
||||
if (!isDefined(subFieldName)) {
|
||||
throw new Error(
|
||||
`Composite field ${fieldMetadata.name} requires a subfield to be specified`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[fieldMetadata.name]: {
|
||||
[subFieldName]: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isDateField) {
|
||||
const dateGroupByObject = buildDateGroupByObject({
|
||||
dateGranularity,
|
||||
firstDayOfTheWeek,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
return { [fieldMetadata.name]: dateGroupByObject };
|
||||
}
|
||||
|
||||
return { [fieldMetadata.name]: true };
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
|
||||
export const chartDataGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof ChartDataException) {
|
||||
switch (error.code) {
|
||||
case ChartDataExceptionCode.WIDGET_NOT_FOUND:
|
||||
case ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND:
|
||||
case ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND:
|
||||
throw new NotFoundError(error.message);
|
||||
case ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION:
|
||||
throw new UserInputError(error.message);
|
||||
case ChartDataExceptionCode.QUERY_EXECUTION_FAILED:
|
||||
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
|
||||
throw new InternalServerError(error.message);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
isFieldMetadataNumericKind,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { isCyclicalDateGranularity } from 'src/modules/dashboard/chart-data/utils/is-cyclical-date-granularity.util';
|
||||
|
||||
const parseDate = (
|
||||
rawValue: RawDimensionValue | undefined,
|
||||
): Temporal.PlainDate | null => {
|
||||
if (!isDefined(rawValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stringValue = String(rawValue);
|
||||
|
||||
return Temporal.PlainDate.from(stringValue);
|
||||
};
|
||||
|
||||
type CompareDimensionValuesParams = {
|
||||
rawValueA: RawDimensionValue | undefined;
|
||||
rawValueB: RawDimensionValue | undefined;
|
||||
formattedValueA: string;
|
||||
formattedValueB: string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
fieldType?: FieldMetadataType;
|
||||
subFieldName?: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
};
|
||||
|
||||
export const compareDimensionValues = ({
|
||||
rawValueA,
|
||||
rawValueB,
|
||||
formattedValueA,
|
||||
formattedValueB,
|
||||
direction,
|
||||
fieldType,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
}: CompareDimensionValuesParams): number => {
|
||||
const applyDirection = (comparison: number) =>
|
||||
direction === 'ASC' ? comparison : -comparison;
|
||||
|
||||
if (isDefined(fieldType)) {
|
||||
if (
|
||||
isFieldMetadataDateKind(fieldType) &&
|
||||
!isCyclicalDateGranularity(dateGranularity)
|
||||
) {
|
||||
const dateA = parseDate(rawValueA);
|
||||
const dateB = parseDate(rawValueB);
|
||||
|
||||
if (isDefined(dateA) && isDefined(dateB)) {
|
||||
return applyDirection(Temporal.PlainDate.compare(dateA, dateB));
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldType === FieldMetadataType.CURRENCY) {
|
||||
if (subFieldName === 'amountMicros') {
|
||||
if (isDefined(rawValueA) && isDefined(rawValueB)) {
|
||||
return applyDirection(Number(rawValueA) - Number(rawValueB));
|
||||
}
|
||||
}
|
||||
|
||||
return applyDirection(formattedValueA.localeCompare(formattedValueB));
|
||||
}
|
||||
|
||||
if (isFieldMetadataNumericKind(fieldType)) {
|
||||
if (isDefined(rawValueA) && isDefined(rawValueB)) {
|
||||
return applyDirection(Number(rawValueA) - Number(rawValueB));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return applyDirection(formattedValueA.localeCompare(formattedValueB));
|
||||
};
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
type CompositeFieldSubFieldName,
|
||||
type FilterableAndTSVectorFieldType,
|
||||
type PartialFieldMetadataItem,
|
||||
type RecordFilterGroupLogicalOperator,
|
||||
type ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
isDefined,
|
||||
type RecordFilter,
|
||||
type RecordFilterGroup,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { type ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type';
|
||||
|
||||
type ConvertChartFilterToGqlOperationFilterParams = {
|
||||
filter: ChartFilter | undefined;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
userTimezone: string;
|
||||
};
|
||||
|
||||
export const convertChartFilterToGqlOperationFilter = ({
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
userTimezone,
|
||||
}: ConvertChartFilterToGqlOperationFilterParams): ObjectRecordFilter => {
|
||||
if (!isDefined(filter)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const recordFilters = filter.recordFilters ?? [];
|
||||
const recordFilterGroups = filter.recordFilterGroups ?? [];
|
||||
|
||||
if (recordFilters.length === 0 && recordFilterGroups.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const fieldMetadataIds = flatObjectMetadata.fieldMetadataIds ?? [];
|
||||
const fields: PartialFieldMetadataItem[] = fieldMetadataIds
|
||||
.map((fieldId: string) => {
|
||||
const field = flatFieldMetadataMaps.byId[fieldId];
|
||||
|
||||
if (!isDefined(field)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
label: field.label,
|
||||
options: field.options?.map((opt) => ({
|
||||
id: opt.id ?? '',
|
||||
label: opt.label,
|
||||
value: opt.value,
|
||||
color: 'color' in opt ? opt.color : undefined,
|
||||
position: opt.position,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
const convertedRecordFilters: RecordFilter[] = recordFilters.map(
|
||||
(recordFilter) => {
|
||||
const field = flatFieldMetadataMaps.byId[recordFilter.fieldMetadataId];
|
||||
|
||||
return {
|
||||
id: recordFilter.id,
|
||||
fieldMetadataId: recordFilter.fieldMetadataId,
|
||||
value: recordFilter.value ?? '',
|
||||
type: (field?.type ??
|
||||
recordFilter.type ??
|
||||
'') as FilterableAndTSVectorFieldType,
|
||||
recordFilterGroupId: recordFilter.recordFilterGroupId ?? undefined,
|
||||
operand: recordFilter.operand as ViewFilterOperand,
|
||||
subFieldName: (recordFilter.subFieldName ?? undefined) as
|
||||
| CompositeFieldSubFieldName
|
||||
| undefined,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const convertedRecordFilterGroups: RecordFilterGroup[] =
|
||||
recordFilterGroups.map((recordFilterGroup) => ({
|
||||
id: recordFilterGroup.id,
|
||||
parentRecordFilterGroupId:
|
||||
recordFilterGroup.parentRecordFilterGroupId ?? undefined,
|
||||
logicalOperator:
|
||||
recordFilterGroup.logicalOperator as RecordFilterGroupLogicalOperator,
|
||||
}));
|
||||
|
||||
return computeRecordGqlOperationFilter({
|
||||
fields,
|
||||
recordFilters: convertedRecordFilters,
|
||||
recordFilterGroups: convertedRecordFilterGroups,
|
||||
filterValueDependencies: {
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined, sortPlainDate } from 'twenty-shared/utils';
|
||||
|
||||
import { DATE_GRANULARITIES_WITHOUT_GAP_FILLING } from 'src/modules/dashboard/chart-data/constants/date-granularities-without-gap-filling.constant';
|
||||
import { type SupportedDateGranularityForGapFilling } from 'src/modules/dashboard/chart-data/constants/supported-date-granularity-for-gap-filling.type';
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { generateDateGroupsInRange } from 'src/modules/dashboard/chart-data/utils/generate-date-groups-in-range.util';
|
||||
|
||||
type FillDateGapsResult = {
|
||||
data: GroupByRawResult[];
|
||||
wasTruncated: boolean;
|
||||
};
|
||||
|
||||
type FillDateGapsParams = {
|
||||
data: GroupByRawResult[];
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity | null | undefined;
|
||||
isDescOrder?: boolean;
|
||||
};
|
||||
|
||||
export const fillDateGaps = ({
|
||||
data,
|
||||
dateGranularity,
|
||||
isDescOrder = false,
|
||||
}: FillDateGapsParams): FillDateGapsResult => {
|
||||
if (data.length === 0) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(dateGranularity) ||
|
||||
DATE_GRANULARITIES_WITHOUT_GAP_FILLING.has(dateGranularity)
|
||||
) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
|
||||
const parsedDates: Temporal.PlainDate[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const dateValue = item.groupByDimensionValues?.[0];
|
||||
|
||||
if (!isDefined(dateValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedDate = Temporal.PlainDate.from(String(dateValue));
|
||||
|
||||
parsedDates.push(parsedDate);
|
||||
existingDateGroupsMap.set(parsedDate.toString(), item);
|
||||
}
|
||||
|
||||
if (parsedDates.length === 0) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const sortedPlainDates = [...parsedDates].sort(sortPlainDate('asc'));
|
||||
|
||||
const minDate = sortedPlainDates[0];
|
||||
const maxDate = sortedPlainDates[sortedPlainDates.length - 1];
|
||||
|
||||
if (!isDefined(minDate) || !isDefined(maxDate)) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const { dates: allDates, wasTruncated } = generateDateGroupsInRange({
|
||||
startDate: minDate,
|
||||
endDate: maxDate,
|
||||
granularity: dateGranularity as SupportedDateGranularityForGapFilling,
|
||||
});
|
||||
|
||||
const orderedDates = isDescOrder ? [...allDates].reverse() : allDates;
|
||||
|
||||
const filledData = orderedDates.map((date) => {
|
||||
const key = date.toString();
|
||||
const existingDateGroup = existingDateGroupsMap.get(key);
|
||||
|
||||
if (isDefined(existingDateGroup)) {
|
||||
return existingDateGroup;
|
||||
}
|
||||
|
||||
return {
|
||||
groupByDimensionValues: [date.toString()],
|
||||
aggregateValue: 0,
|
||||
};
|
||||
});
|
||||
|
||||
return { data: filledData, wasTruncated };
|
||||
};
|
||||
|
||||
type FillDateGapsTwoDimensionalParams = {
|
||||
data: GroupByRawResult[];
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity | null | undefined;
|
||||
isDescOrder?: boolean;
|
||||
};
|
||||
|
||||
export const fillDateGapsTwoDimensional = ({
|
||||
data,
|
||||
dateGranularity,
|
||||
isDescOrder = false,
|
||||
}: FillDateGapsTwoDimensionalParams): FillDateGapsResult => {
|
||||
if (data.length === 0) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
if (
|
||||
!isDefined(dateGranularity) ||
|
||||
DATE_GRANULARITIES_WITHOUT_GAP_FILLING.has(dateGranularity)
|
||||
) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
|
||||
const parsedDates: Temporal.PlainDate[] = [];
|
||||
const uniqueSecondDimensionValues = new Set<unknown>();
|
||||
|
||||
for (const item of data) {
|
||||
const dateValue = item.groupByDimensionValues?.[0];
|
||||
|
||||
if (!isDefined(dateValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedDate = Temporal.PlainDate.from(String(dateValue));
|
||||
|
||||
parsedDates.push(parsedDate);
|
||||
|
||||
const secondDimensionValue = item.groupByDimensionValues?.[1] ?? null;
|
||||
|
||||
uniqueSecondDimensionValues.add(secondDimensionValue);
|
||||
|
||||
const key = `${parsedDate.toString()}_${String(secondDimensionValue)}`;
|
||||
|
||||
existingDateGroupsMap.set(key, item);
|
||||
}
|
||||
|
||||
if (parsedDates.length === 0) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const sortedPlainDates = [...parsedDates].sort(sortPlainDate('asc'));
|
||||
|
||||
const minDate = sortedPlainDates[0];
|
||||
const maxDate = sortedPlainDates[sortedPlainDates.length - 1];
|
||||
|
||||
if (!isDefined(minDate) || !isDefined(maxDate)) {
|
||||
return { data, wasTruncated: false };
|
||||
}
|
||||
|
||||
const { dates: allDates, wasTruncated } = generateDateGroupsInRange({
|
||||
startDate: minDate,
|
||||
endDate: maxDate,
|
||||
granularity: dateGranularity as SupportedDateGranularityForGapFilling,
|
||||
});
|
||||
|
||||
const orderedDates = isDescOrder ? [...allDates].reverse() : allDates;
|
||||
|
||||
const filledData = orderedDates.flatMap((date) =>
|
||||
Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => {
|
||||
const key = `${date.toString()}_${String(secondDimensionValue)}`;
|
||||
const existingDateGroup = existingDateGroupsMap.get(key);
|
||||
|
||||
if (isDefined(existingDateGroup)) {
|
||||
return existingDateGroup;
|
||||
}
|
||||
|
||||
return {
|
||||
groupByDimensionValues: [date.toString(), secondDimensionValue],
|
||||
aggregateValue: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { data: filledData, wasTruncated };
|
||||
};
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
|
||||
type FillSelectGapsParams = {
|
||||
data: GroupByRawResult[];
|
||||
selectOptions: FieldMetadataOption[] | null | undefined;
|
||||
};
|
||||
|
||||
export const fillSelectGaps = ({
|
||||
data,
|
||||
selectOptions,
|
||||
}: FillSelectGapsParams): GroupByRawResult[] => {
|
||||
if (
|
||||
!isDefined(selectOptions) ||
|
||||
selectOptions.length === 0 ||
|
||||
data.length === 0
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const existingGroupsMap = new Map<string, GroupByRawResult>();
|
||||
|
||||
for (const item of data) {
|
||||
const dimensionValue = item.groupByDimensionValues?.[0];
|
||||
|
||||
if (isDefined(dimensionValue)) {
|
||||
existingGroupsMap.set(String(dimensionValue), item);
|
||||
}
|
||||
}
|
||||
|
||||
const filledData: GroupByRawResult[] = selectOptions.map((option) => {
|
||||
const existingGroup = existingGroupsMap.get(option.value);
|
||||
|
||||
if (isDefined(existingGroup)) {
|
||||
return existingGroup;
|
||||
}
|
||||
|
||||
return {
|
||||
groupByDimensionValues: [option.value],
|
||||
aggregateValue: 0,
|
||||
};
|
||||
});
|
||||
|
||||
return filledData;
|
||||
};
|
||||
|
||||
type FillSelectGapsTwoDimensionalParams = {
|
||||
data: GroupByRawResult[];
|
||||
selectOptions: FieldMetadataOption[] | null | undefined;
|
||||
};
|
||||
|
||||
export const fillSelectGapsTwoDimensional = ({
|
||||
data,
|
||||
selectOptions,
|
||||
}: FillSelectGapsTwoDimensionalParams): GroupByRawResult[] => {
|
||||
if (
|
||||
!isDefined(selectOptions) ||
|
||||
selectOptions.length === 0 ||
|
||||
data.length === 0
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const existingGroupsMap = new Map<string, GroupByRawResult>();
|
||||
const uniqueSecondDimensionValues = new Set<unknown>();
|
||||
|
||||
for (const item of data) {
|
||||
const primaryValue = item.groupByDimensionValues?.[0];
|
||||
const secondaryValue = item.groupByDimensionValues?.[1] ?? null;
|
||||
|
||||
if (isDefined(primaryValue)) {
|
||||
const key = `${String(primaryValue)}_${String(secondaryValue)}`;
|
||||
|
||||
existingGroupsMap.set(key, item);
|
||||
uniqueSecondDimensionValues.add(secondaryValue);
|
||||
}
|
||||
}
|
||||
|
||||
const filledData: GroupByRawResult[] = selectOptions.flatMap((option) =>
|
||||
Array.from(uniqueSecondDimensionValues).map((secondaryValue) => {
|
||||
const key = `${option.value}_${String(secondaryValue)}`;
|
||||
const existingGroup = existingGroupsMap.get(key);
|
||||
|
||||
if (isDefined(existingGroup)) {
|
||||
return existingGroup;
|
||||
}
|
||||
|
||||
return {
|
||||
groupByDimensionValues: [option.value, secondaryValue],
|
||||
aggregateValue: 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return filledData;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
|
||||
export const filterByRange = (
|
||||
results: GroupByRawResult[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): GroupByRawResult[] => {
|
||||
return results.filter((result) => {
|
||||
const value = result.aggregateValue;
|
||||
|
||||
if (isDefined(rangeMin) && value < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && value > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const filterLineChartXValuesByRange = (
|
||||
xValues: string[],
|
||||
seriesMap: Map<string, Map<string, number>>,
|
||||
seriesIds: string[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): string[] => {
|
||||
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
|
||||
return xValues;
|
||||
}
|
||||
|
||||
return xValues.filter((xValue) => {
|
||||
const totalValue = seriesIds.reduce((sum, seriesId) => {
|
||||
const xToYMap = seriesMap.get(seriesId) ?? new Map();
|
||||
const yValue = xToYMap.get(xValue) ?? 0;
|
||||
|
||||
return sum + (isNumber(yValue) ? yValue : 0);
|
||||
}, 0);
|
||||
|
||||
if (isDefined(rangeMin) && totalValue < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && totalValue > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const filterTwoDimensionalDataByRange = <
|
||||
T extends Record<string, string | number>,
|
||||
>(
|
||||
data: T[],
|
||||
keys: string[],
|
||||
rangeMin?: number | null,
|
||||
rangeMax?: number | null,
|
||||
): T[] => {
|
||||
if (!isDefined(rangeMin) && !isDefined(rangeMax)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter((datum) => {
|
||||
const totalValue = keys.reduce((sum, key) => {
|
||||
const value = datum[key];
|
||||
|
||||
return sum + (isNumber(value) ? value : 0);
|
||||
}, 0);
|
||||
|
||||
if (isDefined(rangeMin) && totalValue < rangeMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDefined(rangeMax) && totalValue > rangeMax) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { getNextPeriodStart, getPeriodStart } from 'twenty-shared/utils';
|
||||
|
||||
export const formatDateByGranularity = (
|
||||
plainDate: Temporal.PlainDate,
|
||||
granularity:
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.MONTH
|
||||
| ObjectRecordGroupByDateGranularity.QUARTER
|
||||
| ObjectRecordGroupByDateGranularity.YEAR
|
||||
| ObjectRecordGroupByDateGranularity.WEEK
|
||||
| ObjectRecordGroupByDateGranularity.NONE,
|
||||
userTimezone: string,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek,
|
||||
): string => {
|
||||
switch (granularity) {
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
return plainDate.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.WEEK: {
|
||||
const startOfWeek = getPeriodStart(
|
||||
plainDate.toZonedDateTime(userTimezone),
|
||||
'WEEK',
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
|
||||
const endOfWeek = getNextPeriodStart(
|
||||
plainDate.toZonedDateTime(userTimezone),
|
||||
'WEEK',
|
||||
firstDayOfTheWeek,
|
||||
).subtract({ days: 1 });
|
||||
|
||||
const startMonth = startOfWeek.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
});
|
||||
const endMonth = endOfWeek.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
});
|
||||
const startDay = startOfWeek.day;
|
||||
const endDay = endOfWeek.day;
|
||||
const startYear = startOfWeek.year;
|
||||
const endYear = endOfWeek.year;
|
||||
|
||||
if (startYear !== endYear) {
|
||||
return `${startMonth} ${startDay}, ${startYear} - ${endMonth} ${endDay}, ${endYear}`;
|
||||
}
|
||||
|
||||
if (startMonth !== endMonth) {
|
||||
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${endYear}`;
|
||||
}
|
||||
|
||||
return `${startMonth} ${startDay} - ${endDay}, ${endYear}`;
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
return plainDate.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER: {
|
||||
return `Q${Math.ceil(plainDate.month / 3)} ${plainDate.year}`;
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
return plainDate.year.toString();
|
||||
case ObjectRecordGroupByDateGranularity.NONE:
|
||||
default:
|
||||
return plainDate.toLocaleString();
|
||||
}
|
||||
};
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString, isNumber } from '@sniptt/guards';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
formatToShortNumber,
|
||||
isDefined,
|
||||
parseToPlainDateOrThrow,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
|
||||
import { formatDateByGranularity } from 'src/modules/dashboard/chart-data/utils/format-date-by-granularity';
|
||||
|
||||
type FormatDimensionValueParams = {
|
||||
value: unknown;
|
||||
fieldMetadata: FlatFieldMetadata;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
subFieldName?: string;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
const normalizeMultiSelectValue = (value: unknown): unknown[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const isPostgresArrayFormat =
|
||||
trimmed.startsWith('{') && trimmed.endsWith('}');
|
||||
|
||||
if (!isPostgresArrayFormat) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
const content = trimmed.slice(1, -1);
|
||||
|
||||
return content ? content.split(',') : [];
|
||||
};
|
||||
|
||||
export const formatDimensionValue = ({
|
||||
value,
|
||||
fieldMetadata,
|
||||
dateGranularity,
|
||||
subFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: FormatDimensionValueParams): string => {
|
||||
if (!isDefined(value)) {
|
||||
return t`Not Set`;
|
||||
}
|
||||
|
||||
const effectiveDateGranularity = (dateGranularity ??
|
||||
GRAPH_DEFAULT_DATE_GRANULARITY) as ObjectRecordGroupByDateGranularity;
|
||||
|
||||
switch (fieldMetadata.type) {
|
||||
case FieldMetadataType.SELECT: {
|
||||
const selectedOption = fieldMetadata.options?.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
|
||||
return selectedOption?.label ?? String(value);
|
||||
}
|
||||
|
||||
case FieldMetadataType.MULTI_SELECT: {
|
||||
const values = normalizeMultiSelectValue(value);
|
||||
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = fieldMetadata.options?.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
|
||||
return option?.label ?? String(value);
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
case FieldMetadataType.BOOLEAN: {
|
||||
return value === true ? t`Yes` : t`No`;
|
||||
}
|
||||
|
||||
case FieldMetadataType.DATE:
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
if (
|
||||
effectiveDateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
|
||||
effectiveDateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR ||
|
||||
effectiveDateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const parsedPlainDate = parseToPlainDateOrThrow(String(value));
|
||||
|
||||
return formatDateByGranularity(
|
||||
parsedPlainDate,
|
||||
effectiveDateGranularity,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
}
|
||||
|
||||
case FieldMetadataType.RELATION: {
|
||||
if (isDefined(dateGranularity)) {
|
||||
const parsedDayString = String(value);
|
||||
|
||||
if (
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR ||
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return parsedDayString;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
case FieldMetadataType.NUMBER:
|
||||
case FieldMetadataType.CURRENCY: {
|
||||
if (
|
||||
fieldMetadata.type === FieldMetadataType.CURRENCY &&
|
||||
subFieldName === 'currencyCode'
|
||||
) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
return t`Not Set`;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
const numericValue = isNumber(value) ? value : Number(value);
|
||||
|
||||
if (isNaN(numericValue)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return formatToShortNumber(numericValue);
|
||||
}
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import {
|
||||
assertUnreachable,
|
||||
isPlainDateBeforeOrEqual,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant';
|
||||
import { type SupportedDateGranularityForGapFilling } from 'src/modules/dashboard/chart-data/constants/supported-date-granularity-for-gap-filling.type';
|
||||
|
||||
type GenerateDateGroupsInRangeParams = {
|
||||
startDate: Temporal.PlainDate;
|
||||
endDate: Temporal.PlainDate;
|
||||
granularity: SupportedDateGranularityForGapFilling;
|
||||
};
|
||||
|
||||
type GenerateDateGroupsInRangeResult = {
|
||||
dates: Temporal.PlainDate[];
|
||||
wasTruncated: boolean;
|
||||
};
|
||||
|
||||
export const generateDateGroupsInRange = ({
|
||||
startDate,
|
||||
endDate,
|
||||
granularity,
|
||||
}: GenerateDateGroupsInRangeParams): GenerateDateGroupsInRangeResult => {
|
||||
const dates: Temporal.PlainDate[] = [];
|
||||
|
||||
let iterations = 0;
|
||||
let wasTruncated = false;
|
||||
|
||||
let currentDateCursor = startDate;
|
||||
|
||||
while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) {
|
||||
if (iterations >= BAR_CHART_MAXIMUM_NUMBER_OF_BARS) {
|
||||
wasTruncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dates.push(currentDateCursor);
|
||||
iterations++;
|
||||
|
||||
switch (granularity) {
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
currentDateCursor = currentDateCursor.add({ days: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.WEEK:
|
||||
currentDateCursor = currentDateCursor.add({ weeks: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
currentDateCursor = currentDateCursor.add({ months: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER:
|
||||
currentDateCursor = currentDateCursor.add({ months: 3 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
currentDateCursor = currentDateCursor.add({ years: 1 });
|
||||
break;
|
||||
|
||||
default:
|
||||
assertUnreachable(granularity);
|
||||
}
|
||||
}
|
||||
|
||||
return { dates, wasTruncated };
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
|
||||
export const getAggregateOperationLabel = (
|
||||
operation: AggregateOperations,
|
||||
): string => {
|
||||
switch (operation) {
|
||||
case AggregateOperations.MIN:
|
||||
return 'Min';
|
||||
case AggregateOperations.MAX:
|
||||
return 'Max';
|
||||
case AggregateOperations.AVG:
|
||||
return 'Average';
|
||||
case AggregateOperations.SUM:
|
||||
return 'Sum';
|
||||
case AggregateOperations.COUNT:
|
||||
return 'Count all';
|
||||
case AggregateOperations.COUNT_EMPTY:
|
||||
return 'Count empty';
|
||||
case AggregateOperations.COUNT_NOT_EMPTY:
|
||||
return 'Count not empty';
|
||||
case AggregateOperations.COUNT_UNIQUE_VALUES:
|
||||
return 'Count unique values';
|
||||
case AggregateOperations.PERCENTAGE_EMPTY:
|
||||
return 'Percent empty';
|
||||
case AggregateOperations.PERCENTAGE_NOT_EMPTY:
|
||||
return 'Percent not empty';
|
||||
case AggregateOperations.COUNT_TRUE:
|
||||
return 'Count true';
|
||||
case AggregateOperations.COUNT_FALSE:
|
||||
return 'Count false';
|
||||
default:
|
||||
return 'Count';
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import {
|
||||
ChartDataException,
|
||||
ChartDataExceptionCode,
|
||||
generateChartDataExceptionMessage,
|
||||
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
|
||||
|
||||
export const getFieldMetadata = (
|
||||
fieldMetadataId: string,
|
||||
fieldMetadataById: Partial<Record<string, FlatFieldMetadata>>,
|
||||
): FlatFieldMetadata => {
|
||||
const fieldMetadata = fieldMetadataById[fieldMetadataId];
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
throw new ChartDataException(
|
||||
generateChartDataExceptionMessage(
|
||||
ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
fieldMetadataId,
|
||||
),
|
||||
ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata;
|
||||
};
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type ObjectRecordOrderByWithGroupByDateField,
|
||||
type OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
|
||||
import { getRelationFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util';
|
||||
|
||||
export const getFieldOrderBy = (
|
||||
groupByFieldMetadata: FlatFieldMetadata,
|
||||
groupBySubFieldName: string | null | undefined,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity | undefined,
|
||||
direction: OrderByDirection,
|
||||
):
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByForRelationField => {
|
||||
if (isCompositeFieldMetadataType(groupByFieldMetadata.type)) {
|
||||
if (!isDefined(groupBySubFieldName)) {
|
||||
throw new Error(
|
||||
`Group by subFieldName is required for composite fields (field: ${groupByFieldMetadata.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[groupByFieldMetadata.name]: {
|
||||
[groupBySubFieldName]: direction,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isFieldMetadataDateKind(groupByFieldMetadata.type)) {
|
||||
return {
|
||||
[groupByFieldMetadata.name]: {
|
||||
orderBy: direction,
|
||||
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isMorphOrRelationFlatFieldMetadata(groupByFieldMetadata)) {
|
||||
return getRelationFieldOrderBy(
|
||||
groupByFieldMetadata,
|
||||
groupBySubFieldName,
|
||||
direction,
|
||||
dateGranularity,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
[groupByFieldMetadata.name]: direction,
|
||||
};
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type AggregateOrderByWithGroupByField,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type ObjectRecordOrderByWithGroupByDateField,
|
||||
} from 'twenty-shared/types';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { buildAggregateFieldKey } from 'src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util';
|
||||
import { getFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-field-order-by.util';
|
||||
import { mapOrderByToDirection } from 'src/modules/dashboard/chart-data/utils/map-order-by-to-direction.util';
|
||||
|
||||
export const getGroupByOrderBy = ({
|
||||
graphOrderBy,
|
||||
groupByFieldMetadata,
|
||||
groupBySubFieldName,
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata,
|
||||
dateGranularity,
|
||||
}: {
|
||||
graphOrderBy: GraphOrderBy;
|
||||
groupByFieldMetadata: FlatFieldMetadata;
|
||||
groupBySubFieldName?: string | null;
|
||||
aggregateOperation?: AggregateOperations;
|
||||
aggregateFieldMetadata?: FlatFieldMetadata;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
}):
|
||||
| AggregateOrderByWithGroupByField
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByForRelationField
|
||||
| undefined => {
|
||||
switch (graphOrderBy) {
|
||||
case GraphOrderBy.FIELD_ASC:
|
||||
case GraphOrderBy.FIELD_DESC:
|
||||
return getFieldOrderBy(
|
||||
groupByFieldMetadata,
|
||||
groupBySubFieldName,
|
||||
dateGranularity,
|
||||
mapOrderByToDirection(graphOrderBy),
|
||||
);
|
||||
case GraphOrderBy.VALUE_ASC:
|
||||
case GraphOrderBy.VALUE_DESC: {
|
||||
if (
|
||||
!isDefined(aggregateOperation) ||
|
||||
!isDefined(aggregateFieldMetadata)
|
||||
) {
|
||||
throw new Error(
|
||||
`Aggregate operation or field metadata not found (field: ${groupByFieldMetadata.name})`,
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateFieldKey = buildAggregateFieldKey({
|
||||
aggregateOperation,
|
||||
aggregateFieldMetadata,
|
||||
});
|
||||
|
||||
return {
|
||||
aggregate: {
|
||||
[aggregateFieldKey]: mapOrderByToDirection(graphOrderBy),
|
||||
},
|
||||
};
|
||||
}
|
||||
case GraphOrderBy.FIELD_POSITION_ASC:
|
||||
case GraphOrderBy.FIELD_POSITION_DESC:
|
||||
case GraphOrderBy.MANUAL:
|
||||
return undefined;
|
||||
default:
|
||||
assertUnreachable(graphOrderBy);
|
||||
}
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type OrderByDirection,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
|
||||
|
||||
export const getRelationFieldOrderBy = (
|
||||
groupByFieldMetadata: FlatFieldMetadata,
|
||||
groupBySubFieldName: string | null | undefined,
|
||||
direction: OrderByDirection,
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity,
|
||||
isNestedDateField?: boolean,
|
||||
): ObjectRecordOrderByForScalarField | ObjectRecordOrderByForRelationField => {
|
||||
if (!isDefined(groupBySubFieldName)) {
|
||||
return {
|
||||
[`${groupByFieldMetadata.name}Id`]: direction,
|
||||
};
|
||||
}
|
||||
|
||||
const [nestedFieldName, nestedSubFieldName] = groupBySubFieldName.split('.');
|
||||
|
||||
if (isNestedDateField === true || isDefined(dateGranularity)) {
|
||||
return {
|
||||
[groupByFieldMetadata.name]: {
|
||||
[nestedFieldName]: {
|
||||
orderBy: direction,
|
||||
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDefined(nestedSubFieldName)) {
|
||||
return {
|
||||
[groupByFieldMetadata.name]: {
|
||||
[nestedFieldName]: direction,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
[groupByFieldMetadata.name]: {
|
||||
[nestedFieldName]: {
|
||||
[nestedSubFieldName]: direction,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { isFieldMetadataSelectKind } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
|
||||
|
||||
export const getSelectOptions = (
|
||||
fieldMetadata: FlatFieldMetadata,
|
||||
): FieldMetadataOption[] | null => {
|
||||
if (!isFieldMetadataSelectKind(fieldMetadata.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = fieldMetadata.options as FieldMetadataOption[] | undefined;
|
||||
|
||||
return options ?? null;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const isCyclicalDateGranularity = (
|
||||
granularity?: ObjectRecordGroupByDateGranularity | null,
|
||||
): boolean => {
|
||||
if (!isDefined(granularity)) return false;
|
||||
|
||||
return [
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
|
||||
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR,
|
||||
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR,
|
||||
].includes(granularity);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
export const isRelationNestedFieldDateKind = ({
|
||||
relationFieldMetadata,
|
||||
relationNestedFieldName,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
relationFieldMetadata: FlatFieldMetadata;
|
||||
relationNestedFieldName: string | undefined;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): boolean => {
|
||||
if (!isDefined(relationNestedFieldName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMorphOrRelationFlatFieldMetadata(relationFieldMetadata)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetObjectId = relationFieldMetadata.relationTargetObjectMetadataId;
|
||||
|
||||
if (!isDefined(targetObjectId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetObjectMetadata = flatObjectMetadataMaps.byId[targetObjectId];
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nestedFieldName = relationNestedFieldName.split('.')[0];
|
||||
|
||||
const targetFieldIds = targetObjectMetadata.fieldMetadataIds;
|
||||
|
||||
const nestedFieldMetadata = targetFieldIds
|
||||
.map((fieldId: string) => flatFieldMetadataMaps.byId[fieldId])
|
||||
.find(
|
||||
(fieldMetadata: FlatFieldMetadata | undefined) =>
|
||||
isDefined(fieldMetadata) && fieldMetadata.name === nestedFieldName,
|
||||
);
|
||||
|
||||
if (!isDefined(nestedFieldMetadata)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isFieldMetadataDateKind(nestedFieldMetadata.type);
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { OrderByDirection } from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
|
||||
export const mapOrderByToDirection = (
|
||||
orderByEnum:
|
||||
| GraphOrderBy.FIELD_ASC
|
||||
| GraphOrderBy.FIELD_DESC
|
||||
| GraphOrderBy.VALUE_ASC
|
||||
| GraphOrderBy.VALUE_DESC,
|
||||
): OrderByDirection => {
|
||||
switch (orderByEnum) {
|
||||
case GraphOrderBy.FIELD_ASC:
|
||||
return OrderByDirection.AscNullsLast;
|
||||
case GraphOrderBy.FIELD_DESC:
|
||||
return OrderByDirection.DescNullsLast;
|
||||
case GraphOrderBy.VALUE_ASC:
|
||||
return OrderByDirection.AscNullsLast;
|
||||
case GraphOrderBy.VALUE_DESC:
|
||||
return OrderByDirection.DescNullsLast;
|
||||
|
||||
default:
|
||||
assertUnreachable(orderByEnum);
|
||||
}
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { formatDimensionValue } from 'src/modules/dashboard/chart-data/utils/format-dimension-value.util';
|
||||
|
||||
export type ProcessedOneDimensionalDataPoint = {
|
||||
formattedValue: string;
|
||||
rawValue: RawDimensionValue;
|
||||
aggregateValue: number;
|
||||
};
|
||||
|
||||
export type ProcessOneDimensionalResultsOutput = {
|
||||
processedDataPoints: ProcessedOneDimensionalDataPoint[];
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
};
|
||||
|
||||
type ProcessOneDimensionalResultsParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
subFieldName?: string | null;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
export const processOneDimensionalResults = ({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
dateGranularity,
|
||||
subFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: ProcessOneDimensionalResultsParams): ProcessOneDimensionalResultsOutput => {
|
||||
const formattedToRawLookup = new Map<string, RawDimensionValue>();
|
||||
const processedDataPoints: ProcessedOneDimensionalDataPoint[] = [];
|
||||
|
||||
for (const result of rawResults) {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
|
||||
if (!isDefined(dimensionValues) || dimensionValues.length < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValue = dimensionValues[0] as RawDimensionValue;
|
||||
|
||||
const formattedValue = formatDimensionValue({
|
||||
value: rawValue,
|
||||
fieldMetadata: primaryAxisGroupByField,
|
||||
dateGranularity: dateGranularity ?? undefined,
|
||||
subFieldName: subFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
if (isDefined(rawValue)) {
|
||||
formattedToRawLookup.set(formattedValue, rawValue);
|
||||
}
|
||||
|
||||
processedDataPoints.push({
|
||||
formattedValue,
|
||||
rawValue,
|
||||
aggregateValue: result.aggregateValue,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
processedDataPoints,
|
||||
formattedToRawLookup,
|
||||
};
|
||||
};
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { formatDimensionValue } from 'src/modules/dashboard/chart-data/utils/format-dimension-value.util';
|
||||
|
||||
export type ProcessedTwoDimensionalDataPoint = {
|
||||
xFormatted: string;
|
||||
yFormatted: string;
|
||||
rawXValue: RawDimensionValue;
|
||||
rawYValue: RawDimensionValue;
|
||||
aggregateValue: number;
|
||||
};
|
||||
|
||||
export type ProcessTwoDimensionalResultsOutput = {
|
||||
processedDataPoints: ProcessedTwoDimensionalDataPoint[];
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
secondaryFormattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
};
|
||||
|
||||
type ProcessTwoDimensionalResultsParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
primaryAxisGroupByField: FlatFieldMetadata;
|
||||
secondaryAxisGroupByField: FlatFieldMetadata;
|
||||
primaryDateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
primarySubFieldName?: string | null;
|
||||
secondaryDateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
secondarySubFieldName?: string | null;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
export const processTwoDimensionalResults = ({
|
||||
rawResults,
|
||||
primaryAxisGroupByField,
|
||||
secondaryAxisGroupByField,
|
||||
primaryDateGranularity,
|
||||
primarySubFieldName,
|
||||
secondaryDateGranularity,
|
||||
secondarySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: ProcessTwoDimensionalResultsParams): ProcessTwoDimensionalResultsOutput => {
|
||||
const formattedToRawLookup = new Map<string, RawDimensionValue>();
|
||||
const secondaryFormattedToRawLookup = new Map<string, RawDimensionValue>();
|
||||
const processedDataPoints: ProcessedTwoDimensionalDataPoint[] = [];
|
||||
|
||||
for (const result of rawResults) {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
|
||||
if (!isDefined(dimensionValues) || dimensionValues.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawXValue = dimensionValues[0] as RawDimensionValue;
|
||||
const rawYValue = dimensionValues[1] as RawDimensionValue;
|
||||
|
||||
const xFormatted = formatDimensionValue({
|
||||
value: rawXValue,
|
||||
fieldMetadata: primaryAxisGroupByField,
|
||||
dateGranularity: primaryDateGranularity ?? undefined,
|
||||
subFieldName: primarySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const yFormatted = formatDimensionValue({
|
||||
value: rawYValue,
|
||||
fieldMetadata: secondaryAxisGroupByField,
|
||||
dateGranularity: secondaryDateGranularity ?? undefined,
|
||||
subFieldName: secondarySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
if (isDefined(rawXValue)) {
|
||||
formattedToRawLookup.set(xFormatted, rawXValue);
|
||||
}
|
||||
|
||||
if (isDefined(rawYValue)) {
|
||||
secondaryFormattedToRawLookup.set(yFormatted, rawYValue);
|
||||
}
|
||||
|
||||
processedDataPoints.push({
|
||||
xFormatted,
|
||||
yFormatted,
|
||||
rawXValue,
|
||||
rawYValue,
|
||||
aggregateValue: result.aggregateValue,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
processedDataPoints,
|
||||
formattedToRawLookup,
|
||||
secondaryFormattedToRawLookup,
|
||||
};
|
||||
};
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type SortByManualOrderParams<T> = {
|
||||
items: T[];
|
||||
manualSortOrder: string[];
|
||||
getRawValue: (item: T) => string | null | undefined;
|
||||
};
|
||||
|
||||
export const sortByManualOrder = <T>({
|
||||
items,
|
||||
manualSortOrder,
|
||||
getRawValue,
|
||||
}: SortByManualOrderParams<T>): T[] => {
|
||||
if (manualSortOrder.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const orderMap = new Map(
|
||||
manualSortOrder.map((value, index) => [value, index]),
|
||||
);
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const rawValueA = getRawValue(a) ?? '';
|
||||
const rawValueB = getRawValue(b) ?? '';
|
||||
|
||||
const indexA = orderMap.get(rawValueA);
|
||||
const indexB = orderMap.get(rawValueB);
|
||||
|
||||
if (!isDefined(indexA) && !isDefined(indexB)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!isDefined(indexA)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!isDefined(indexB)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return indexA - indexB;
|
||||
});
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
|
||||
type FieldMetadataOption = {
|
||||
value: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type SortBySelectOptionPositionParams<T> = {
|
||||
items: T[];
|
||||
options: FieldMetadataOption[];
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
getFormattedValue: (item: T) => string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
};
|
||||
|
||||
export const sortBySelectOptionPosition = <T>({
|
||||
items,
|
||||
options,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction,
|
||||
}: SortBySelectOptionPositionParams<T>): T[] => {
|
||||
const optionValueToPosition = new Map<string, number>();
|
||||
|
||||
for (const option of options) {
|
||||
optionValueToPosition.set(option.value, option.position);
|
||||
}
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const formattedA = getFormattedValue(a);
|
||||
const formattedB = getFormattedValue(b);
|
||||
|
||||
const rawA = formattedToRawLookup.get(formattedA);
|
||||
const rawB = formattedToRawLookup.get(formattedB);
|
||||
|
||||
const positionA = isDefined(rawA)
|
||||
? (optionValueToPosition.get(String(rawA)) ?? Number.MAX_SAFE_INTEGER)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const positionB = isDefined(rawB)
|
||||
? (optionValueToPosition.get(String(rawB)) ?? Number.MAX_SAFE_INTEGER)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return direction === 'ASC' ? positionA - positionB : positionB - positionA;
|
||||
});
|
||||
};
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
type FieldMetadataType,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { compareDimensionValues } from 'src/modules/dashboard/chart-data/utils/compare-dimension-values.util';
|
||||
import { sortByManualOrder } from 'src/modules/dashboard/chart-data/utils/sort-by-manual-order.util';
|
||||
import { sortBySelectOptionPosition } from 'src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util';
|
||||
|
||||
type FieldMetadataOption = {
|
||||
value: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type SortChartDataParams<T> = {
|
||||
data: T[];
|
||||
orderBy?: GraphOrderBy | null;
|
||||
manualSortOrder?: string[] | null;
|
||||
formattedToRawLookup: Map<string, RawDimensionValue>;
|
||||
getFieldValue: (item: T) => string;
|
||||
getNumericValue: (item: T) => number;
|
||||
selectFieldOptions?: FieldMetadataOption[] | null;
|
||||
fieldType?: FieldMetadataType;
|
||||
subFieldName?: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
};
|
||||
|
||||
export const sortChartDataIfNeeded = <T>({
|
||||
data,
|
||||
orderBy,
|
||||
manualSortOrder,
|
||||
formattedToRawLookup,
|
||||
getFieldValue,
|
||||
getNumericValue,
|
||||
selectFieldOptions,
|
||||
fieldType,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
}: SortChartDataParams<T>): T[] => {
|
||||
if (!isDefined(orderBy)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
switch (orderBy) {
|
||||
case GraphOrderBy.MANUAL: {
|
||||
if (!isDefined(manualSortOrder)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return sortByManualOrder({
|
||||
items: data,
|
||||
manualSortOrder,
|
||||
getRawValue: (item) => {
|
||||
const formatted = getFieldValue(item);
|
||||
const raw = formattedToRawLookup.get(formatted);
|
||||
|
||||
return isDefined(raw) ? String(raw) : formatted;
|
||||
},
|
||||
});
|
||||
}
|
||||
case GraphOrderBy.VALUE_ASC:
|
||||
return [...data].sort((a, b) => getNumericValue(a) - getNumericValue(b));
|
||||
case GraphOrderBy.VALUE_DESC:
|
||||
return [...data].sort((a, b) => getNumericValue(b) - getNumericValue(a));
|
||||
case GraphOrderBy.FIELD_ASC:
|
||||
case GraphOrderBy.FIELD_DESC:
|
||||
return [...data].sort((a, b) => {
|
||||
const formattedValueA = getFieldValue(a);
|
||||
const formattedValueB = getFieldValue(b);
|
||||
|
||||
return compareDimensionValues({
|
||||
rawValueA: formattedToRawLookup.get(formattedValueA),
|
||||
rawValueB: formattedToRawLookup.get(formattedValueB),
|
||||
formattedValueA,
|
||||
formattedValueB,
|
||||
direction: orderBy === GraphOrderBy.FIELD_ASC ? 'ASC' : 'DESC',
|
||||
fieldType,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
});
|
||||
});
|
||||
case GraphOrderBy.FIELD_POSITION_ASC:
|
||||
if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return sortBySelectOptionPosition<T>({
|
||||
items: data,
|
||||
options: selectFieldOptions,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue: getFieldValue,
|
||||
direction: 'ASC',
|
||||
});
|
||||
case GraphOrderBy.FIELD_POSITION_DESC:
|
||||
if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return sortBySelectOptionPosition<T>({
|
||||
items: data,
|
||||
options: selectFieldOptions,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue: getFieldValue,
|
||||
direction: 'DESC',
|
||||
});
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
};
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
type FieldMetadataType,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
|
||||
import { type FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
|
||||
import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
|
||||
import { compareDimensionValues } from 'src/modules/dashboard/chart-data/utils/compare-dimension-values.util';
|
||||
import { sortByManualOrder } from 'src/modules/dashboard/chart-data/utils/sort-by-manual-order.util';
|
||||
import { sortBySelectOptionPosition } from 'src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util';
|
||||
|
||||
type SortSecondaryAxisDataParams<T> = {
|
||||
items: T[];
|
||||
orderBy?: GraphOrderBy | null;
|
||||
manualSortOrder?: string[] | null;
|
||||
formattedToRawLookup?: Map<string, RawDimensionValue>;
|
||||
selectFieldOptions?: FieldMetadataOption[] | null;
|
||||
getFormattedValue: (item: T) => string;
|
||||
getNumericValue: (item: T) => number;
|
||||
fieldType?: FieldMetadataType;
|
||||
subFieldName?: string;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
};
|
||||
|
||||
export const sortSecondaryAxisData = <T>({
|
||||
items,
|
||||
orderBy,
|
||||
manualSortOrder,
|
||||
formattedToRawLookup,
|
||||
selectFieldOptions,
|
||||
getFormattedValue,
|
||||
getNumericValue,
|
||||
fieldType,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
}: SortSecondaryAxisDataParams<T>): T[] => {
|
||||
if (!isDefined(orderBy)) {
|
||||
return items;
|
||||
}
|
||||
|
||||
switch (orderBy) {
|
||||
case GraphOrderBy.FIELD_ASC:
|
||||
case GraphOrderBy.FIELD_DESC:
|
||||
return [...items].sort((a, b) => {
|
||||
const formattedValueA = getFormattedValue(a);
|
||||
const formattedValueB = getFormattedValue(b);
|
||||
|
||||
return compareDimensionValues({
|
||||
rawValueA: formattedToRawLookup?.get(formattedValueA),
|
||||
rawValueB: formattedToRawLookup?.get(formattedValueB),
|
||||
formattedValueA,
|
||||
formattedValueB,
|
||||
direction: orderBy === GraphOrderBy.FIELD_ASC ? 'ASC' : 'DESC',
|
||||
fieldType,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
});
|
||||
});
|
||||
|
||||
case GraphOrderBy.VALUE_ASC:
|
||||
return [...items].sort((a, b) => getNumericValue(a) - getNumericValue(b));
|
||||
|
||||
case GraphOrderBy.VALUE_DESC:
|
||||
return [...items].sort((a, b) => getNumericValue(b) - getNumericValue(a));
|
||||
|
||||
case GraphOrderBy.FIELD_POSITION_ASC:
|
||||
case GraphOrderBy.FIELD_POSITION_DESC: {
|
||||
if (
|
||||
!isDefined(selectFieldOptions) ||
|
||||
selectFieldOptions.length === 0 ||
|
||||
!isDefined(formattedToRawLookup)
|
||||
) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return sortBySelectOptionPosition({
|
||||
items,
|
||||
options: selectFieldOptions,
|
||||
formattedToRawLookup,
|
||||
getFormattedValue,
|
||||
direction: orderBy === GraphOrderBy.FIELD_POSITION_ASC ? 'ASC' : 'DESC',
|
||||
});
|
||||
}
|
||||
|
||||
case GraphOrderBy.MANUAL: {
|
||||
if (!isDefined(manualSortOrder)) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return sortByManualOrder({
|
||||
items,
|
||||
manualSortOrder,
|
||||
getRawValue: (item) => {
|
||||
const formattedValue = getFormattedValue(item);
|
||||
const rawValue = formattedToRawLookup?.get(formattedValue);
|
||||
|
||||
return isDefined(rawValue) ? String(rawValue) : formattedValue;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return items;
|
||||
}
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
|
||||
const PERCENT_AGGREGATE_OPERATIONS = new Set([
|
||||
AggregateOperations.PERCENTAGE_EMPTY,
|
||||
AggregateOperations.PERCENTAGE_NOT_EMPTY,
|
||||
]);
|
||||
|
||||
const COUNT_AGGREGATE_OPERATIONS = new Set([
|
||||
AggregateOperations.COUNT,
|
||||
AggregateOperations.COUNT_UNIQUE_VALUES,
|
||||
AggregateOperations.COUNT_EMPTY,
|
||||
AggregateOperations.COUNT_NOT_EMPTY,
|
||||
AggregateOperations.COUNT_TRUE,
|
||||
AggregateOperations.COUNT_FALSE,
|
||||
]);
|
||||
|
||||
type TransformAggregateValueParams = {
|
||||
rawValue: unknown;
|
||||
aggregateFieldType: FieldMetadataType;
|
||||
aggregateOperation: AggregateOperations;
|
||||
};
|
||||
|
||||
export const transformAggregateValue = ({
|
||||
rawValue,
|
||||
aggregateFieldType,
|
||||
aggregateOperation,
|
||||
}: TransformAggregateValueParams): number => {
|
||||
if (!isDefined(rawValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const numericValue = Number(rawValue);
|
||||
|
||||
if (isNaN(numericValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (COUNT_AGGREGATE_OPERATIONS.has(aggregateOperation)) {
|
||||
return numericValue;
|
||||
}
|
||||
|
||||
if (PERCENT_AGGREGATE_OPERATIONS.has(aggregateOperation)) {
|
||||
return numericValue * 100;
|
||||
}
|
||||
|
||||
if (aggregateFieldType === FieldMetadataType.CURRENCY) {
|
||||
return numericValue / 1_000_000;
|
||||
}
|
||||
|
||||
return numericValue;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { ChartDataModule } from 'src/modules/dashboard/chart-data/chart-data.module';
|
||||
import { DashboardController } from 'src/modules/dashboard/controllers/dashboard.controller';
|
||||
import { DashboardResolver } from 'src/modules/dashboard/resolvers/dashboard.resolver';
|
||||
import { DashboardDuplicationService } from 'src/modules/dashboard/services/dashboard-duplication.service';
|
||||
@@ -13,6 +14,7 @@ import { DashboardDuplicationService } from 'src/modules/dashboard/services/dash
|
||||
imports: [
|
||||
ActorModule,
|
||||
AuthModule,
|
||||
ChartDataModule,
|
||||
PageLayoutModule,
|
||||
TwentyORMModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
|
||||
Reference in New Issue
Block a user