diff --git a/packages/twenty-front/codegen.cjs b/packages/twenty-front/codegen.cjs index 262769bd9c..f372b63f67 100644 --- a/packages/twenty-front/codegen.cjs +++ b/packages/twenty-front/codegen.cjs @@ -20,6 +20,7 @@ module.exports = { './src/modules/subscription/graphql/**/*.{ts,tsx}', './src/modules/page-layout/graphql/**/*.{ts,tsx}', + './src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}', '!./src/**/*.test.{ts,tsx}', '!./src/**/*.stories.{ts,tsx}', diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 7c374b5913..7c810f13a3 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -62,8 +62,8 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 51, - lines: 50, + statements: 50, + lines: 49, functions: 41, }, }, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 26fc6533fb..71a2c04333 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -380,6 +380,27 @@ export type BarChartConfiguration = { timezone?: Maybe; }; +export type BarChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type BarChartDataOutput = { + __typename?: 'BarChartDataOutput'; + data: Array; + formattedToRawLookup: Scalars['JSON']; + groupMode: BarChartGroupMode; + hasTooManyGroups: Scalars['Boolean']; + indexBy: Scalars['String']; + keys: Array; + layout: BarChartLayout; + series: Array; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; + xAxisLabel: Scalars['String']; + yAxisLabel: Scalars['String']; +}; + /** Display mode for bar charts with secondary grouping */ export enum BarChartGroupMode { GROUPED = 'GROUPED', @@ -392,6 +413,12 @@ export enum BarChartLayout { VERTICAL = 'VERTICAL' } +export type BarChartSeries = { + __typename?: 'BarChartSeries'; + key: Scalars['String']; + label: Scalars['String']; +}; + export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; @@ -1851,6 +1878,35 @@ export type LineChartConfiguration = { timezone?: Maybe; }; +export type LineChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type LineChartDataOutput = { + __typename?: 'LineChartDataOutput'; + formattedToRawLookup: Scalars['JSON']; + hasTooManyGroups: Scalars['Boolean']; + series: Array; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; + xAxisLabel: Scalars['String']; + yAxisLabel: Scalars['String']; +}; + +export type LineChartDataPoint = { + __typename?: 'LineChartDataPoint'; + x: Scalars['String']; + y: Scalars['Float']; +}; + +export type LineChartSeries = { + __typename?: 'LineChartSeries'; + data: Array; + id: Scalars['String']; + label: Scalars['String']; +}; + export type LinkMetadata = { __typename?: 'LinkMetadata'; label: Scalars['String']; @@ -3375,6 +3431,27 @@ export type PieChartConfiguration = { timezone?: Maybe; }; +export type PieChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type PieChartDataItem = { + __typename?: 'PieChartDataItem'; + id: Scalars['String']; + value: Scalars['Float']; +}; + +export type PieChartDataOutput = { + __typename?: 'PieChartDataOutput'; + data: Array; + formattedToRawLookup: Scalars['JSON']; + hasTooManyGroups: Scalars['Boolean']; + showCenterMetric: Scalars['Boolean']; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; +}; + export type PlaceDetailsResult = { __typename?: 'PlaceDetailsResult'; city?: Maybe; @@ -3433,6 +3510,7 @@ export type Query = { agentTurns: Array; apiKey?: Maybe; apiKeys: Array; + barChartData: BarChartDataOutput; billingPortalSession: BillingSessionOutput; chatMessages: Array; chatThread: AgentChatThread; @@ -3510,9 +3588,11 @@ export type Query = { getToolIndex: Array; index: Index; indexMetadatas: IndexConnection; + lineChartData: LineChartDataOutput; listPlans: Array; object: Object; objects: ObjectConnection; + pieChartData: PieChartDataOutput; search: SearchResultConnection; skill?: Maybe; skills: Array; @@ -3533,6 +3613,11 @@ export type QueryApiKeyArgs = { }; +export type QueryBarChartDataArgs = { + input: BarChartDataInput; +}; + + export type QueryBillingPortalSessionArgs = { returnUrlPath?: InputMaybe; }; @@ -3832,6 +3917,11 @@ export type QueryIndexMetadatasArgs = { }; +export type QueryLineChartDataArgs = { + input: LineChartDataInput; +}; + + export type QueryObjectArgs = { id: Scalars['UUID']; }; @@ -3843,6 +3933,11 @@ export type QueryObjectsArgs = { }; +export type QueryPieChartDataArgs = { + input: PieChartDataInput; +}; + + export type QuerySearchArgs = { after?: InputMaybe; excludedObjectNameSingulars?: InputMaybe>; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 405c337305..d8d98349f5 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -380,6 +380,27 @@ export type BarChartConfiguration = { timezone?: Maybe; }; +export type BarChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type BarChartDataOutput = { + __typename?: 'BarChartDataOutput'; + data: Array; + formattedToRawLookup: Scalars['JSON']; + groupMode: BarChartGroupMode; + hasTooManyGroups: Scalars['Boolean']; + indexBy: Scalars['String']; + keys: Array; + layout: BarChartLayout; + series: Array; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; + xAxisLabel: Scalars['String']; + yAxisLabel: Scalars['String']; +}; + /** Display mode for bar charts with secondary grouping */ export enum BarChartGroupMode { GROUPED = 'GROUPED', @@ -392,6 +413,12 @@ export enum BarChartLayout { VERTICAL = 'VERTICAL' } +export type BarChartSeries = { + __typename?: 'BarChartSeries'; + key: Scalars['String']; + label: Scalars['String']; +}; + export type Billing = { __typename?: 'Billing'; billingUrl?: Maybe; @@ -1828,6 +1855,35 @@ export type LineChartConfiguration = { timezone?: Maybe; }; +export type LineChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type LineChartDataOutput = { + __typename?: 'LineChartDataOutput'; + formattedToRawLookup: Scalars['JSON']; + hasTooManyGroups: Scalars['Boolean']; + series: Array; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; + xAxisLabel: Scalars['String']; + yAxisLabel: Scalars['String']; +}; + +export type LineChartDataPoint = { + __typename?: 'LineChartDataPoint'; + x: Scalars['String']; + y: Scalars['Float']; +}; + +export type LineChartSeries = { + __typename?: 'LineChartSeries'; + data: Array; + id: Scalars['String']; + label: Scalars['String']; +}; + export type LinkMetadata = { __typename?: 'LinkMetadata'; label: Scalars['String']; @@ -3285,6 +3341,27 @@ export type PieChartConfiguration = { timezone?: Maybe; }; +export type PieChartDataInput = { + configuration: Scalars['JSON']; + objectMetadataId: Scalars['UUID']; +}; + +export type PieChartDataItem = { + __typename?: 'PieChartDataItem'; + id: Scalars['String']; + value: Scalars['Float']; +}; + +export type PieChartDataOutput = { + __typename?: 'PieChartDataOutput'; + data: Array; + formattedToRawLookup: Scalars['JSON']; + hasTooManyGroups: Scalars['Boolean']; + showCenterMetric: Scalars['Boolean']; + showDataLabels: Scalars['Boolean']; + showLegend: Scalars['Boolean']; +}; + export type PlaceDetailsResult = { __typename?: 'PlaceDetailsResult'; city?: Maybe; @@ -3342,6 +3419,7 @@ export type Query = { __typename?: 'Query'; apiKey?: Maybe; apiKeys: Array; + barChartData: BarChartDataOutput; billingPortalSession: BillingSessionOutput; checkUserExists: CheckUserExistOutput; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput; @@ -3414,9 +3492,11 @@ export type Query = { getToolIndex: Array; index: Index; indexMetadatas: IndexConnection; + lineChartData: LineChartDataOutput; listPlans: Array; object: Object; objects: ObjectConnection; + pieChartData: PieChartDataOutput; search: SearchResultConnection; validatePasswordResetToken: ValidatePasswordResetTokenOutput; versionInfo: VersionInfo; @@ -3430,6 +3510,11 @@ export type QueryApiKeyArgs = { }; +export type QueryBarChartDataArgs = { + input: BarChartDataInput; +}; + + export type QueryBillingPortalSessionArgs = { returnUrlPath?: InputMaybe; }; @@ -3692,6 +3777,16 @@ export type QueryGetTimelineThreadsFromPersonIdArgs = { }; +export type QueryLineChartDataArgs = { + input: LineChartDataInput; +}; + + +export type QueryPieChartDataArgs = { + input: PieChartDataInput; +}; + + export type QuerySearchArgs = { after?: InputMaybe; excludedObjectNameSingulars?: InputMaybe>; @@ -5244,6 +5339,27 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{ export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'CalendarConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldRichTextConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FilesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'NotesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | { __typename?: 'TasksConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'TimelineConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'ViewConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowRunConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowVersionConfiguration', configurationType: WidgetConfigurationType } }> | null }> | null } }; +export type BarChartDataQueryVariables = Exact<{ + input: BarChartDataInput; +}>; + + +export type BarChartDataQuery = { __typename?: 'Query', barChartData: { __typename?: 'BarChartDataOutput', data: Array, indexBy: string, keys: Array, xAxisLabel: string, yAxisLabel: string, showLegend: boolean, showDataLabels: boolean, layout: BarChartLayout, groupMode: BarChartGroupMode, hasTooManyGroups: boolean, formattedToRawLookup: any, series: Array<{ __typename?: 'BarChartSeries', key: string, label: string }> } }; + +export type LineChartDataQueryVariables = Exact<{ + input: LineChartDataInput; +}>; + + +export type LineChartDataQuery = { __typename?: 'Query', lineChartData: { __typename?: 'LineChartDataOutput', xAxisLabel: string, yAxisLabel: string, showLegend: boolean, showDataLabels: boolean, hasTooManyGroups: boolean, formattedToRawLookup: any, series: Array<{ __typename?: 'LineChartSeries', id: string, label: string, data: Array<{ __typename?: 'LineChartDataPoint', x: string, y: number }> }> } }; + +export type PieChartDataQueryVariables = Exact<{ + input: PieChartDataInput; +}>; + + +export type PieChartDataQuery = { __typename?: 'Query', pieChartData: { __typename?: 'PieChartDataOutput', showLegend: boolean, showDataLabels: boolean, showCenterMetric: boolean, hasTooManyGroups: boolean, formattedToRawLookup: any, data: Array<{ __typename?: 'PieChartDataItem', id: string, value: number }> } }; + export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; export type ViewFilterFragmentFragment = { __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -5915,6 +6031,146 @@ export function useUpdatePageLayoutWithTabsAndWidgetsMutation(baseOptions?: Apol export type UpdatePageLayoutWithTabsAndWidgetsMutationHookResult = ReturnType; export type UpdatePageLayoutWithTabsAndWidgetsMutationResult = Apollo.MutationResult; export type UpdatePageLayoutWithTabsAndWidgetsMutationOptions = Apollo.BaseMutationOptions; +export const BarChartDataDocument = gql` + query BarChartData($input: BarChartDataInput!) { + barChartData(input: $input) { + data + indexBy + keys + series { + key + label + } + xAxisLabel + yAxisLabel + showLegend + showDataLabels + layout + groupMode + hasTooManyGroups + formattedToRawLookup + } +} + `; + +/** + * __useBarChartDataQuery__ + * + * To run a query within a React component, call `useBarChartDataQuery` and pass it any options that fit your needs. + * When your component renders, `useBarChartDataQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useBarChartDataQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useBarChartDataQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(BarChartDataDocument, options); + } +export function useBarChartDataLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(BarChartDataDocument, options); + } +export type BarChartDataQueryHookResult = ReturnType; +export type BarChartDataLazyQueryHookResult = ReturnType; +export type BarChartDataQueryResult = Apollo.QueryResult; +export const LineChartDataDocument = gql` + query LineChartData($input: LineChartDataInput!) { + lineChartData(input: $input) { + series { + id + label + data { + x + y + } + } + xAxisLabel + yAxisLabel + showLegend + showDataLabels + hasTooManyGroups + formattedToRawLookup + } +} + `; + +/** + * __useLineChartDataQuery__ + * + * To run a query within a React component, call `useLineChartDataQuery` and pass it any options that fit your needs. + * When your component renders, `useLineChartDataQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useLineChartDataQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useLineChartDataQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(LineChartDataDocument, options); + } +export function useLineChartDataLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(LineChartDataDocument, options); + } +export type LineChartDataQueryHookResult = ReturnType; +export type LineChartDataLazyQueryHookResult = ReturnType; +export type LineChartDataQueryResult = Apollo.QueryResult; +export const PieChartDataDocument = gql` + query PieChartData($input: PieChartDataInput!) { + pieChartData(input: $input) { + data { + id + value + } + showLegend + showDataLabels + showCenterMetric + hasTooManyGroups + formattedToRawLookup + } +} + `; + +/** + * __usePieChartDataQuery__ + * + * To run a query within a React component, call `usePieChartDataQuery` and pass it any options that fit your needs. + * When your component renders, `usePieChartDataQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = usePieChartDataQuery({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function usePieChartDataQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(PieChartDataDocument, options); + } +export function usePieChartDataLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(PieChartDataDocument, options); + } +export type PieChartDataQueryHookResult = ReturnType; +export type PieChartDataLazyQueryHookResult = ReturnType; +export type PieChartDataQueryResult = Apollo.QueryResult; export const CreateCoreViewDocument = gql` mutation CreateCoreView($input: CreateViewInput!) { createCoreView(input: $input) { diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx index b9f39fc7a5..892f48d65b 100644 --- a/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingCreditsSection.tsx @@ -11,11 +11,11 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; +import { formatToShortNumber } from 'twenty-shared/utils'; import { H2Title } from 'twenty-ui/display'; import { ProgressBar } from 'twenty-ui/feedback'; import { Section } from 'twenty-ui/layout'; import { SubscriptionStatus } from '~/generated/graphql'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; const StyledLineSeparator = styled.div` width: 100%; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isFieldOrNestedFieldDateKind.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isFieldOrNestedFieldDateKind.test.ts index dcdfc4bdb7..8d8b4f0cc2 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isFieldOrNestedFieldDateKind.test.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isFieldOrNestedFieldDateKind.test.ts @@ -1,6 +1,6 @@ +import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page-layout/utils/isFieldOrNestedFieldDateKind'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { FieldMetadataType } from 'twenty-shared/types'; -import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page-layout/utils/isFieldOrNestedFieldDateKind'; describe('isFieldOrNestedFieldDateKind', () => { it('returns false when fieldId is null', () => { @@ -47,12 +47,13 @@ describe('isFieldOrNestedFieldDateKind', () => { { id: 'relation-field-id', type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, + relation: { targetObjectMetadata: { id: 'company-id' } }, }, ], } as ObjectMetadataItem; const companyObjectMetadataItem = { + id: 'company-id', nameSingular: 'company', fields: [ { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts index 97dbc02434..a78ae1ef79 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts @@ -1,11 +1,11 @@ import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/shouldHideChartSetting'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { msg } from '@lingui/core/macro'; import { FieldMetadataType } from 'twenty-shared/types'; import { IconChartBar } from 'twenty-ui/display'; -import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/shouldHideChartSetting'; describe('shouldHideChartSetting', () => { const mockItemWithoutDependencies: ChartSettingsItem = { @@ -390,7 +390,9 @@ describe('shouldHideChartSetting', () => { name: 'company', label: 'Company', type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, + relation: { + targetObjectMetadata: { id: 'company-id', nameSingular: 'company' }, + }, }; const targetObjectMetadata: any = { diff --git a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue.ts index 45427987c1..f2eaefc421 100644 --- a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue.ts @@ -6,10 +6,9 @@ import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-tabl import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { FieldMetadataType, type Nullable } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { formatToShortNumber, isDefined } from 'twenty-shared/utils'; import { type AggregateOperations } from '~/generated-metadata/graphql'; import { formatNumber } from '~/utils/format/formatNumber'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; import { formatDateString } from '~/utils/string/formatDateString'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/NumberFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/NumberFieldDisplay.tsx index 02d2e4fd23..505f2c189f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/NumberFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/NumberFieldDisplay.tsx @@ -1,8 +1,7 @@ import { useNumberFormat } from '@/localization/hooks/useNumberFormat'; import { useNumberFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useNumberFieldDisplay'; import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay'; -import { isDefined } from 'twenty-shared/utils'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; +import { formatToShortNumber, isDefined } from 'twenty-shared/utils'; export const NumberFieldDisplay = () => { const { fieldValue, fieldDefinition } = useNumberFieldDisplay(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups.ts deleted file mode 100644 index c3f26caef0..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups.ts +++ /dev/null @@ -1,2 +0,0 @@ -// TODO: Remove this once backend returns total group count -export const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.ts deleted file mode 100644 index e95c056109..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.ts +++ /dev/null @@ -1 +0,0 @@ -export const GRAPH_DEFAULT_AGGREGATE_VALUE = 0; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultOrderBy.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultOrderBy.ts deleted file mode 100644 index 63efe79e71..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultOrderBy.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GraphOrderBy } from '~/generated/graphql'; - -export const GRAPH_DEFAULT_ORDER_BY = GraphOrderBy.FIELD_ASC; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index 3b3b587505..74988d083d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -10,7 +10,7 @@ import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme'; import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys'; @@ -49,7 +49,7 @@ type GraphWidgetBarChartProps = { data: BarDatum[]; indexBy: string; keys: string[]; - series?: BarChartSeries[]; + series?: BarChartSeriesWithColor[]; showLegend?: boolean; showGrid?: boolean; showValues?: boolean; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx index b866bcda6c..88faedded3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -3,7 +3,6 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData'; import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice'; -import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; import { assertBarChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertBarChartWidget'; import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; @@ -17,7 +16,8 @@ import { lazy, Suspense } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { AppPath } from 'twenty-shared/types'; -import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { getAppPath } from 'twenty-shared/utils'; +import { AxisNameDisplay } from '~/generated/graphql'; const GraphWidgetBarChart = lazy(() => import( @@ -45,6 +45,7 @@ export const GraphWidgetBarChartRenderer = () => { showDataLabels, showLegend, layout, + groupMode, loading, hasTooManyGroups, formattedToRawLookup, @@ -61,13 +62,19 @@ export const GraphWidgetBarChartRenderer = () => { isPageLayoutInEditModeComponentState, ); - const hasGroupByOnSecondaryAxis = isDefined( - configuration.secondaryAxisGroupByFieldMetadataId, - ); - const groupMode = getEffectiveGroupMode( - configuration.groupMode, - hasGroupByOnSecondaryAxis, - ); + const axisNameDisplay = configuration.axisNameDisplay; + + const showXLabel = + axisNameDisplay === AxisNameDisplay.X || + axisNameDisplay === AxisNameDisplay.BOTH; + + const showYLabel = + axisNameDisplay === AxisNameDisplay.Y || + axisNameDisplay === AxisNameDisplay.BOTH; + + const xAxisLabelToDisplay = showXLabel ? xAxisLabel : undefined; + const yAxisLabelToDisplay = showYLabel ? yAxisLabel : undefined; + const chartFilterKey = generateChartAggregateFilterKey( configuration.rangeMin, configuration.rangeMax, @@ -125,8 +132,8 @@ export const GraphWidgetBarChartRenderer = () => { series={series} indexBy={indexBy} keys={keys} - xAxisLabel={xAxisLabel} - yAxisLabel={yAxisLabel} + xAxisLabel={xAxisLabelToDisplay} + yAxisLabel={yAxisLabelToDisplay} showValues={showDataLabels} showLegend={showLegend} layout={layout} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts index d79d1e35e8..5779225b64 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/__tests__/useBarChartData.test.ts @@ -1,8 +1,8 @@ -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData'; +import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { type BarDatum } from '@nivo/bar'; import { renderHook } from '@testing-library/react'; -import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData'; const mockUseRecoilComponentValue = jest.fn(); jest.mock( @@ -63,7 +63,7 @@ describe('useBarChartData', () => { { month: 'Mar', sales: 150, costs: 100 }, ]; - const mockSeries: BarChartSeries[] = [ + const mockSeries: BarChartSeriesWithColor[] = [ { key: 'sales', label: 'Sales', color: 'green' }, { key: 'costs', label: 'Costs', color: 'purple' }, ]; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts index b6046686f4..8cb6ba3d6a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData.ts @@ -1,7 +1,7 @@ import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig'; import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; @@ -16,7 +16,7 @@ type UseBarChartDataProps = { data: BarDatum[]; indexBy: string; keys: string[]; - series?: BarChartSeries[]; + series?: BarChartSeriesWithColor[]; colorRegistry: GraphColorRegistry; seriesLabels?: Record; groupMode?: 'grouped' | 'stacked'; @@ -37,7 +37,10 @@ export const useBarChartData = ({ ); const seriesConfigMap = useMemo( - () => new Map(series?.map((s) => [s.key, s]) || []), + () => + new Map( + series?.map((s) => [s.key, s]) || [], + ), [series], ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts index 616cc46b89..a024b25b0c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -1,18 +1,25 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit'; -import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; -import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { BAR_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/barChartData'; +import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; +import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; +import { extractBarChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractBarChartDataConfiguration'; +import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; +import { useQuery } from '@apollo/client'; import { type BarDatum } from '@nivo/bar'; +import { isString } from '@sniptt/guards'; import { useMemo } from 'react'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type BarChartConfiguration, type BarChartLayout, + type BarChartSeries, } from '~/generated/graphql'; type UseGraphBarChartWidgetDataProps = { @@ -24,12 +31,13 @@ type UseGraphBarChartWidgetDataResult = { data: BarDatum[]; indexBy: string; keys: string[]; - series: BarChartSeries[]; - xAxisLabel?: string; - yAxisLabel?: string; + series: BarChartSeriesWithColor[]; + xAxisLabel: string; + yAxisLabel: string; showDataLabels: boolean; showLegend: boolean; layout?: BarChartLayout; + groupMode: 'grouped' | 'stacked' | undefined; loading: boolean; error?: Error; hasTooManyGroups: boolean; @@ -47,48 +55,102 @@ export const useGraphBarChartWidgetData = ({ const { objectMetadataItem } = useObjectMetadataItemById({ objectId: objectMetadataItemId, }); - const { objectMetadataItems } = useObjectMetadataItems(); - const { userTimezone } = useUserTimezone(); - const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const apolloCoreClient = useApolloCoreClient(); - const limit = getBarChartQueryLimit(configuration); + const dataConfiguration = useMemo( + () => extractBarChartDataConfiguration(configuration), + [configuration], + ); const { - data: groupByData, + data: queryData, loading, error, - aggregateOperation, - } = useGraphWidgetGroupByQuery({ - objectMetadataItemId, - configuration, - limit, + } = useQuery(BAR_CHART_DATA, { + client: apolloCoreClient, + variables: { + input: { + objectMetadataId: objectMetadataItemId, + configuration: dataConfiguration, + }, + }, }); - const transformedData = useMemo( - () => - transformGroupByDataToBarChartData({ - groupByData, - objectMetadataItem, - objectMetadataItems: objectMetadataItems ?? [], - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek: userFirstDayOfTheWeek, - }), - [ - groupByData, - objectMetadataItem, - objectMetadataItems, - configuration, - aggregateOperation, - userTimezone, - userFirstDayOfTheWeek, - ], + const chartData = (queryData?.barChartData?.data as BarDatum[]) ?? []; + + const formattedToRawLookup = queryData?.barChartData?.formattedToRawLookup + ? new Map(Object.entries(queryData.barChartData.formattedToRawLookup)) + : new Map(); + + const colorDeterminingFieldId = isDefined( + configuration.secondaryAxisGroupByFieldMetadataId, + ) + ? configuration.secondaryAxisGroupByFieldMetadataId + : configuration.primaryAxisGroupByFieldMetadataId; + + const colorDeterminingField = objectMetadataItem?.fields?.find( + (field) => field.id === colorDeterminingFieldId, + ); + + const selectFieldOptions = useMemo((): FieldMetadataItemOption[] | null => { + if (!isDefined(colorDeterminingField)) { + return null; + } + + const isSelectField = + colorDeterminingField.type === FieldMetadataType.SELECT || + colorDeterminingField.type === FieldMetadataType.MULTI_SELECT; + + if (!isSelectField || !isDefined(colorDeterminingField.options)) { + return null; + } + + return colorDeterminingField.options; + }, [colorDeterminingField]); + + const configurationColor = parseGraphColor(configuration.color); + + const colorMode = determineGraphColorMode({ + configurationColor, + selectFieldOptions, + }); + + const series = queryData?.barChartData?.series?.map( + (seriesItem: BarChartSeries): BarChartSeriesWithColor => { + const rawValue = formattedToRawLookup.get(seriesItem.key); + + const itemColor = determineChartItemColor({ + configurationColor, + selectOptions: selectFieldOptions, + rawValue: isString(rawValue) ? rawValue : undefined, + }); + + return { + key: seriesItem.key, + label: seriesItem.label, + color: itemColor, + }; + }, ); return { - ...transformedData, + data: chartData, + indexBy: queryData?.barChartData?.indexBy ?? 'id', + keys: queryData?.barChartData?.keys ?? [], + series, + xAxisLabel: queryData?.barChartData?.xAxisLabel ?? '', + yAxisLabel: queryData?.barChartData?.yAxisLabel ?? '', + showDataLabels: configuration.displayDataLabel ?? false, + showLegend: configuration.displayLegend ?? true, + layout: queryData?.barChartData?.layout, + groupMode: getEffectiveGroupMode( + configuration.groupMode, + configuration.secondaryAxisGroupByFieldMetadataId, + ), + hasTooManyGroups: queryData?.barChartData?.hasTooManyGroups ?? false, + colorMode, + formattedToRawLookup, objectMetadataItem, loading, error, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries.ts index 50acbf19a4..a0cfca8e2e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries.ts @@ -1,7 +1,6 @@ import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; +import { type BarChartSeries } from '~/generated/graphql'; -export type BarChartSeries = { - key: string; - label?: string; +export type BarChartSeriesWithColor = BarChartSeries & { color?: GraphColor; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts deleted file mode 100644 index 77106000ff..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; - -export type FillDateGapsResult = { - data: GroupByRawResult[]; - wasTruncated: boolean; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap deleted file mode 100644 index c42a22ccf1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`applyCumulativeTransformToBarChartData should handle basic accumulation 1`] = ` -[ - { - "id": "a", - "value": 10, - }, - { - "id": "b", - "value": 30, - }, - { - "id": "c", - "value": 60, - }, -] -`; - -exports[`applyCumulativeTransformToBarChartData should handle empty data 1`] = `[]`; - -exports[`applyCumulativeTransformToBarChartData should handle filter above rangeMax 1`] = ` -[ - { - "id": "a", - "value": 10, - }, -] -`; - -exports[`applyCumulativeTransformToBarChartData should handle filter below rangeMin 1`] = ` -[ - { - "id": "b", - "value": 20, - }, - { - "id": "c", - "value": 30, - }, -] -`; - -exports[`applyCumulativeTransformToBarChartData should handle skip non-numeric values 1`] = ` -[ - { - "id": "a", - "value": 10, - }, - { - "id": "b", - "value": 10, - }, - { - "id": "c", - "value": 30, - }, -] -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap deleted file mode 100644 index 9088061dd5..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle accumulate each key independently 1`] = ` -[ - { - "id": "Jan", - "revenue": 100, - "sales": 10, - }, - { - "id": "Feb", - "revenue": 300, - "sales": 30, - }, - { - "id": "Mar", - "revenue": 600, - "sales": 60, - }, -] -`; - -exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle empty data 1`] = `[]`; - -exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter above rangeMax based on total sum 1`] = ` -[ - { - "id": "a", - "x": 10, - "y": 10, - }, -] -`; - -exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter below rangeMin based on total sum 1`] = ` -[ - { - "id": "c", - "x": 30, - "y": 30, - }, -] -`; - -exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle skip non-numeric values 1`] = ` -[ - { - "id": "a", - "x": 10, - }, - { - "id": "b", - "x": 10, - }, - { - "id": "c", - "x": 30, - }, -] -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts deleted file mode 100644 index 04d9fd782b..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData'; - -describe('applyCumulativeTransformToBarChartData', () => { - const testCases = [ - { - name: 'basic accumulation', - data: [ - { id: 'a', value: 10 }, - { id: 'b', value: 20 }, - { id: 'c', value: 30 }, - ], - aggregateKey: 'value', - }, - { - name: 'filter below rangeMin', - data: [ - { id: 'a', value: 10 }, - { id: 'b', value: 10 }, - { id: 'c', value: 10 }, - ], - aggregateKey: 'value', - rangeMin: 15, - }, - { - name: 'filter above rangeMax', - data: [ - { id: 'a', value: 10 }, - { id: 'b', value: 20 }, - { id: 'c', value: 30 }, - ], - aggregateKey: 'value', - rangeMax: 25, - }, - { - name: 'empty data', - data: [], - aggregateKey: 'value', - }, - { - name: 'skip non-numeric values', - data: [ - { id: 'a', value: 10 }, - { id: 'b', value: 'not a number' }, - { id: 'c', value: 20 }, - ], - aggregateKey: 'value', - }, - ]; - - it.each(testCases)( - 'should handle $name', - ({ data, aggregateKey, rangeMin, rangeMax }) => { - const result = applyCumulativeTransformToBarChartData({ - data, - aggregateKey, - rangeMin, - rangeMax, - }); - - expect(result).toMatchSnapshot(); - }, - ); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts deleted file mode 100644 index c7f48d8912..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData'; - -describe('applyCumulativeTransformToTwoDimensionalBarChartData', () => { - const testCases = [ - { - name: 'accumulate each key independently', - data: [ - { id: 'Jan', sales: 10, revenue: 100 }, - { id: 'Feb', sales: 20, revenue: 200 }, - { id: 'Mar', sales: 30, revenue: 300 }, - ], - keys: ['sales', 'revenue'], - }, - { - name: 'filter below rangeMin based on total sum', - data: [ - { id: 'a', x: 10, y: 10 }, - { id: 'b', x: 10, y: 10 }, - { id: 'c', x: 10, y: 10 }, - ], - keys: ['x', 'y'], - rangeMin: 50, - }, - { - name: 'filter above rangeMax based on total sum', - data: [ - { id: 'a', x: 10, y: 10 }, - { id: 'b', x: 20, y: 20 }, - { id: 'c', x: 30, y: 30 }, - ], - keys: ['x', 'y'], - rangeMax: 50, - }, - { - name: 'empty data', - data: [], - keys: ['x', 'y'], - }, - { - name: 'skip non-numeric values', - data: [ - { id: 'a', x: 10 }, - { id: 'b', x: 'not a number' }, - { id: 'c', x: 20 }, - ], - keys: ['x'], - }, - ]; - - it.each(testCases)( - 'should handle $name', - ({ data, keys, rangeMin, rangeMax }) => { - const result = applyCumulativeTransformToTwoDimensionalBarChartData({ - data, - keys, - rangeMin, - rangeMax, - }); - - expect(result).toMatchSnapshot(); - }, - ); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartEndLineCoordinates.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartEndLineCoordinates.test.ts deleted file mode 100644 index 136519bb7c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartEndLineCoordinates.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { calculateBarChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates'; -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; -import { BarChartLayout } from '~/generated/graphql'; - -describe('calculateBarChartEndLineCoordinates', () => { - const createMockBar = ( - overrides?: Partial>, - ): ComputedBarDatum => - ({ - x: 100, - y: 50, - width: 40, - height: 80, - color: 'url(#gradient-test)', - data: { - id: 'sales', - value: 100, - index: 0, - indexValue: 'Q1', - data: { Q1: 100 } as BarDatum, - formattedValue: '100', - hidden: false, - }, - label: 'Sales', - ...overrides, - }) as ComputedBarDatum; - describe('vertical layout', () => { - it('should calculate horizontal line coordinates at the top of vertical bars', () => { - const mockBar = createMockBar(); - const result = calculateBarChartEndLineCoordinates( - mockBar, - BarChartLayout.VERTICAL, - ); - expect(result).toEqual({ - x1: 100, - x2: 140, - y1: 50, - y2: 50, - }); - }); - it('should handle bars at origin position', () => { - const barAtOrigin = createMockBar({ x: 0, y: 0 }); - const result = calculateBarChartEndLineCoordinates( - barAtOrigin, - BarChartLayout.VERTICAL, - ); - expect(result).toEqual({ - x1: 0, - x2: 40, - y1: 0, - y2: 0, - }); - }); - it('should handle bars with negative positions', () => { - const negativeBar = createMockBar({ x: -50, y: -20 }); - const result = calculateBarChartEndLineCoordinates( - negativeBar, - BarChartLayout.VERTICAL, - ); - expect(result).toEqual({ - x1: -50, - x2: -10, - y1: -20, - y2: -20, - }); - }); - }); - describe('horizontal layout', () => { - it('should calculate vertical line coordinates at the end of horizontal bars', () => { - const mockBar = createMockBar(); - const result = calculateBarChartEndLineCoordinates( - mockBar, - BarChartLayout.HORIZONTAL, - ); - expect(result).toEqual({ - x1: 140, - x2: 140, - y1: 50, - y2: 130, - }); - }); - it('should handle bars with different dimensions', () => { - const wideBar = createMockBar({ width: 100, height: 20 }); - const result = calculateBarChartEndLineCoordinates( - wideBar, - BarChartLayout.HORIZONTAL, - ); - expect(result).toEqual({ - x1: 200, - x2: 200, - y1: 50, - y2: 70, - }); - }); - it('should handle very thin bars', () => { - const thinBar = createMockBar({ width: 1, height: 200 }); - const result = calculateBarChartEndLineCoordinates( - thinBar, - BarChartLayout.HORIZONTAL, - ); - expect(result).toEqual({ - x1: 101, - x2: 101, - y1: 50, - y2: 250, - }); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts new file mode 100644 index 0000000000..18f86dbeda --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateWidthPerTick.test.ts @@ -0,0 +1,98 @@ +import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick'; +import { BarChartLayout } from '~/generated/graphql'; + +describe('calculateWidthPerTick', () => { + describe('vertical layout', () => { + it('should calculate width per tick based on categoryTickCount for vertical layout', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.VERTICAL, + availableWidth: 500, + categoryTickCount: 10, + valueTickCount: 5, + }); + + expect(result).toBe(50); + }); + + it('should return 0 when categoryTickCount is 0 for vertical layout', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.VERTICAL, + availableWidth: 500, + categoryTickCount: 0, + valueTickCount: 5, + }); + + expect(result).toBe(0); + }); + + it('should handle small available width', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.VERTICAL, + availableWidth: 100, + categoryTickCount: 20, + valueTickCount: 5, + }); + + expect(result).toBe(5); + }); + }); + + describe('horizontal layout', () => { + it('should calculate width per tick based on valueTickCount for horizontal layout', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.HORIZONTAL, + availableWidth: 600, + categoryTickCount: 10, + valueTickCount: 6, + }); + + expect(result).toBe(100); + }); + + it('should return 0 when valueTickCount is 0 for horizontal layout', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.HORIZONTAL, + availableWidth: 500, + categoryTickCount: 10, + valueTickCount: 0, + }); + + expect(result).toBe(0); + }); + + it('should handle decimal results', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.HORIZONTAL, + availableWidth: 100, + categoryTickCount: 10, + valueTickCount: 3, + }); + + expect(result).toBeCloseTo(33.33, 1); + }); + }); + + describe('edge cases', () => { + it('should handle availableWidth of 0', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.VERTICAL, + availableWidth: 0, + categoryTickCount: 10, + valueTickCount: 5, + }); + + expect(result).toBe(0); + }); + + it('should handle single tick', () => { + const result = calculateWidthPerTick({ + layout: BarChartLayout.VERTICAL, + availableWidth: 500, + categoryTickCount: 1, + valueTickCount: 5, + }); + + expect(result).toBe(500); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts new file mode 100644 index 0000000000..11714e7582 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/computeBarChartGroupedLabels.test.ts @@ -0,0 +1,191 @@ +import { computeBarChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels'; +import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; + +type MockBarData = { + id?: string; + indexValue?: string; + value?: number; +}; + +describe('computeBarChartGroupedLabels', () => { + const createMockBar = (overrides: { + x?: number; + y?: number; + width?: number; + height?: number; + data?: MockBarData; + }): ComputedBarDatum => + ({ + x: overrides.x ?? 0, + y: overrides.y ?? 0, + width: overrides.width ?? 50, + height: overrides.height ?? 100, + data: { + id: overrides.data?.id ?? 'bar1', + indexValue: overrides.data?.indexValue ?? 'Category1', + value: overrides.data?.value ?? 100, + }, + }) as unknown as ComputedBarDatum; + + describe('basic label computation', () => { + it('should return labels for each bar', () => { + const bars = [ + createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 100 } }), + createMockBar({ data: { id: 'bar2', indexValue: 'Cat2', value: 200 } }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result).toHaveLength(2); + }); + + it('should generate unique keys for each label', () => { + const bars = [ + createMockBar({ data: { id: 'sales', indexValue: 'Jan', value: 100 } }), + createMockBar({ data: { id: 'sales', indexValue: 'Feb', value: 150 } }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].key).toBe('value-sales-Jan'); + expect(result[1].key).toBe('value-sales-Feb'); + }); + }); + + describe('label positioning', () => { + it('should calculate center X position correctly', () => { + const bars = [createMockBar({ x: 100, width: 50 })]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].verticalX).toBe(125); + }); + + it('should calculate center Y position for horizontal labels', () => { + const bars = [createMockBar({ y: 50, height: 100 })]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].horizontalY).toBe(100); + }); + + it('should set verticalY to top of bar for positive values', () => { + const bars = [ + createMockBar({ + y: 50, + height: 100, + data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].verticalY).toBe(50); + expect(result[0].shouldRenderBelow).toBe(false); + }); + + it('should set verticalY to bottom of bar for negative values', () => { + const bars = [ + createMockBar({ + y: 50, + height: 100, + data: { id: 'bar1', indexValue: 'Cat1', value: -100 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].verticalY).toBe(150); + expect(result[0].shouldRenderBelow).toBe(true); + }); + + it('should set horizontalX to right edge for positive values', () => { + const bars = [ + createMockBar({ + x: 50, + width: 100, + data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].horizontalX).toBe(150); + }); + + it('should set horizontalX to left edge for negative values', () => { + const bars = [ + createMockBar({ + x: 50, + width: 100, + data: { id: 'bar1', indexValue: 'Cat1', value: -100 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].horizontalX).toBe(50); + }); + }); + + describe('value handling', () => { + it('should extract numeric value from bar data', () => { + const bars = [ + createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 42 } }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].value).toBe(42); + }); + + it('should handle zero values', () => { + const bars = [ + createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 0 } }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].value).toBe(0); + expect(result[0].shouldRenderBelow).toBe(false); + }); + + it('should handle decimal values', () => { + const bars = [ + createMockBar({ + data: { id: 'bar1', indexValue: 'Cat1', value: 123.456 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result[0].value).toBe(123.456); + }); + }); + + describe('edge cases', () => { + it('should return empty array for empty input', () => { + const result = computeBarChartGroupedLabels([]); + + expect(result).toEqual([]); + }); + + it('should handle bars with zero dimensions', () => { + const bars = [ + createMockBar({ + x: 0, + y: 0, + width: 0, + height: 0, + data: { id: 'bar1', indexValue: 'Cat1', value: 100 }, + }), + ]; + + const result = computeBarChartGroupedLabels(bars); + + expect(result).toHaveLength(1); + expect(result[0].verticalX).toBe(0); + expect(result[0].horizontalY).toBe(0); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts deleted file mode 100644 index 8b5cea2156..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { GraphOrderBy } from '~/generated/graphql'; -import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData'; - -describe('fillDateGapsInBarChartData', () => { - describe('one-dimensional data', () => { - it('fills gaps in date data with zero values', () => { - const data = [ - { - groupByDimensionValues: ['2024-01-01'], - count: 5, - }, - { - groupByDimensionValues: ['2024-01-03'], - count: 3, - }, - ]; - - const result = fillDateGapsInBarChartData({ - data, - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - }); - - expect(result.data).toHaveLength(3); - expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-01'], - count: 5, - }); - expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02'], - count: 0, - }); - expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-03'], - count: 3, - }); - expect(result.wasTruncated).toBe(false); - }); - - it('returns empty data unchanged', () => { - const result = fillDateGapsInBarChartData({ - data: [], - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - }); - - expect(result.data).toEqual([]); - expect(result.wasTruncated).toBe(false); - }); - - it('returns data in descending order when orderBy is FIELD_DESC', () => { - const data = [ - { - groupByDimensionValues: ['2024-01-01'], - count: 5, - }, - { - groupByDimensionValues: ['2024-01-03'], - count: 3, - }, - ]; - - const result = fillDateGapsInBarChartData({ - data, - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - orderBy: GraphOrderBy.FIELD_DESC, - }); - - expect(result.data).toHaveLength(3); - expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-03'], - count: 3, - }); - expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02'], - count: 0, - }); - expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-01'], - count: 5, - }); - expect(result.wasTruncated).toBe(false); - }); - - it('returns data in ascending order when orderBy is FIELD_ASC', () => { - const data = [ - { - groupByDimensionValues: ['2024-01-01'], - count: 5, - }, - { - groupByDimensionValues: ['2024-01-03'], - count: 3, - }, - ]; - - const result = fillDateGapsInBarChartData({ - data, - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - orderBy: GraphOrderBy.FIELD_ASC, - }); - - expect(result.data).toHaveLength(3); - expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-01'], - count: 5, - }); - expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02'], - count: 0, - }); - expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-03'], - count: 3, - }); - expect(result.wasTruncated).toBe(false); - }); - }); - - describe('two-dimensional data', () => { - it('fills gaps for all second dimension values', () => { - const data = [ - { - groupByDimensionValues: ['2024-01-01', 'A'], - count: 5, - }, - { - groupByDimensionValues: ['2024-01-03', 'A'], - count: 3, - }, - { - groupByDimensionValues: ['2024-01-01', 'B'], - count: 2, - }, - ]; - - const result = fillDateGapsInBarChartData({ - data, - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - hasSecondDimension: true, - }); - - expect(result.data).toHaveLength(6); - expect( - result.data.filter((r) => r.groupByDimensionValues[1] === 'A'), - ).toHaveLength(3); - expect( - result.data.filter((r) => r.groupByDimensionValues[1] === 'B'), - ).toHaveLength(3); - expect( - result.data.find( - (r) => - r.groupByDimensionValues[0] === '2024-01-02' && - r.groupByDimensionValues[1] === 'A', - ), - ).toEqual({ - groupByDimensionValues: ['2024-01-02', 'A'], - count: 0, - }); - expect(result.wasTruncated).toBe(false); - }); - - it('fills gaps in descending order when orderBy is FIELD_DESC', () => { - const data = [ - { - groupByDimensionValues: ['2024-01-01', 'A'], - count: 5, - }, - { - groupByDimensionValues: ['2024-01-03', 'A'], - count: 3, - }, - { - groupByDimensionValues: ['2024-01-01', 'B'], - count: 2, - }, - ]; - - const result = fillDateGapsInBarChartData({ - data, - keys: ['count'], - dateGranularity: ObjectRecordGroupByDateGranularity.DAY, - hasSecondDimension: true, - orderBy: GraphOrderBy.FIELD_DESC, - }); - - expect(result.data).toHaveLength(6); - - // First date group should be Jan 3 (descending) - expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03'); - expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-03'); - - // Middle date group should be Jan 2 - expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-02'); - expect(result.data[3].groupByDimensionValues[0]).toBe('2024-01-02'); - - // Last date group should be Jan 1 - expect(result.data[4].groupByDimensionValues[0]).toBe('2024-01-01'); - expect(result.data[5].groupByDimensionValues[0]).toBe('2024-01-01'); - - expect(result.wasTruncated).toBe(false); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts deleted file mode 100644 index de573c67fa..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { Temporal } from 'temporal-polyfill'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange'; - -describe('generateDateGroupsInRange', () => { - it('generates daily date groups', () => { - const result = generateDateGroupsInRange({ - startDate: Temporal.PlainDate.from('2024-01-01'), - endDate: Temporal.PlainDate.from('2024-01-07'), - granularity: ObjectRecordGroupByDateGranularity.DAY, - }); - - expect(result.dates).toHaveLength(7); - expect(result.dates[0].toString()).toEqual( - Temporal.PlainDate.from('2024-01-01').toString(), - ); - expect(result.dates[6].toString()).toEqual( - Temporal.PlainDate.from('2024-01-07').toString(), - ); - expect(result.wasTruncated).toBe(false); - }); - - it('generates monthly date groups', () => { - const result = generateDateGroupsInRange({ - startDate: Temporal.PlainDate.from('2024-01-01'), - endDate: Temporal.PlainDate.from('2024-06-01'), - granularity: ObjectRecordGroupByDateGranularity.MONTH, - }); - - expect(result.dates).toHaveLength(6); - expect(result.dates[0].toString()).toEqual( - Temporal.PlainDate.from('2024-01-01').toString(), - ); - expect(result.dates[5].toString()).toEqual( - Temporal.PlainDate.from('2024-06-01').toString(), - ); - expect(result.wasTruncated).toBe(false); - }); - - it('generates quarterly date groups', () => { - const result = generateDateGroupsInRange({ - startDate: Temporal.PlainDate.from('2024-01-01'), - endDate: Temporal.PlainDate.from('2024-12-31'), - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }); - - expect(result.dates).toHaveLength(4); - expect(result.dates[0].toString()).toEqual( - Temporal.PlainDate.from('2024-01-01').toString(), - ); - expect(result.dates[3].toString()).toEqual( - Temporal.PlainDate.from('2024-10-01').toString(), - ); - expect(result.wasTruncated).toBe(false); - }); - - it('generates yearly date groups', () => { - const result = generateDateGroupsInRange({ - startDate: Temporal.PlainDate.from('2020-01-01'), - endDate: Temporal.PlainDate.from('2024-12-31'), - granularity: ObjectRecordGroupByDateGranularity.YEAR, - }); - - expect(result.dates).toHaveLength(5); - expect(result.dates[0].toString()).toEqual( - Temporal.PlainDate.from('2020-01-01').toString(), - ); - expect(result.dates[4].toString()).toEqual( - Temporal.PlainDate.from('2024-01-01').toString(), - ); - expect(result.wasTruncated).toBe(false); - }); - - it('truncates when exceeding maximum number of bars', () => { - const result = generateDateGroupsInRange({ - startDate: Temporal.PlainDate.from('2024-01-01'), - endDate: Temporal.PlainDate.from('2025-12-31'), - granularity: ObjectRecordGroupByDateGranularity.DAY, - }); - - expect(result.dates.length).toBeLessThanOrEqual( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS, - ); - expect(result.dates.length).toBe( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS, - ); - expect(result.wasTruncated).toBe(true); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts new file mode 100644 index 0000000000..d6e1cfac28 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartColor.test.ts @@ -0,0 +1,163 @@ +import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig'; +import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { type BarDatum, type ComputedDatum } from '@nivo/bar'; +import { type ThemeType } from 'twenty-ui/theme'; + +describe('getBarChartColor', () => { + const mockTheme = { + border: { + color: { + light: '#fallback', + }, + }, + } as unknown as ThemeType; + + const mockBlueColorScheme: GraphColorScheme = { + name: 'blue', + solid: '#solidBlue', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + const mockGreenColorScheme: GraphColorScheme = { + name: 'green', + solid: '#solidGreen', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + const mockBarConfigs: BarChartConfig[] = [ + { + key: 'sales', + indexValue: 'January', + colorScheme: mockBlueColorScheme, + }, + { + key: 'revenue', + indexValue: 'January', + colorScheme: mockGreenColorScheme, + }, + { + key: 'sales', + indexValue: 'February', + colorScheme: mockBlueColorScheme, + }, + ]; + + it('should return the correct color when datum matches bar config', () => { + const datum: ComputedDatum = { + id: 'sales', + indexValue: 'January', + } as unknown as ComputedDatum; + + const result = getBarChartColor(datum, mockBarConfigs, mockTheme); + + expect(result).toBe('#solidBlue'); + }); + + it('should return different colors for different keys at same index', () => { + const salesDatum: ComputedDatum = { + id: 'sales', + indexValue: 'January', + } as unknown as ComputedDatum; + + const revenueDatum: ComputedDatum = { + id: 'revenue', + indexValue: 'January', + } as unknown as ComputedDatum; + + const salesColor = getBarChartColor(salesDatum, mockBarConfigs, mockTheme); + const revenueColor = getBarChartColor( + revenueDatum, + mockBarConfigs, + mockTheme, + ); + + expect(salesColor).toBe('#solidBlue'); + expect(revenueColor).toBe('#solidGreen'); + }); + + it('should return theme fallback color when no matching config is found', () => { + const datum: ComputedDatum = { + id: 'unknown', + indexValue: 'January', + } as unknown as ComputedDatum; + + const result = getBarChartColor(datum, mockBarConfigs, mockTheme); + + expect(result).toBe('#fallback'); + }); + + it('should return fallback color when indexValue does not match', () => { + const datum: ComputedDatum = { + id: 'sales', + indexValue: 'March', + } as unknown as ComputedDatum; + + const result = getBarChartColor(datum, mockBarConfigs, mockTheme); + + expect(result).toBe('#fallback'); + }); + + it('should return fallback color when barConfigs is empty', () => { + const datum: ComputedDatum = { + id: 'sales', + indexValue: 'January', + } as unknown as ComputedDatum; + + const result = getBarChartColor(datum, [], mockTheme); + + expect(result).toBe('#fallback'); + }); + + it('should match based on both key and indexValue', () => { + const januaryDatum: ComputedDatum = { + id: 'sales', + indexValue: 'January', + } as unknown as ComputedDatum; + + const februaryDatum: ComputedDatum = { + id: 'sales', + indexValue: 'February', + } as unknown as ComputedDatum; + + const januaryColor = getBarChartColor( + januaryDatum, + mockBarConfigs, + mockTheme, + ); + const februaryColor = getBarChartColor( + februaryDatum, + mockBarConfigs, + mockTheme, + ); + + expect(januaryColor).toBe('#solidBlue'); + expect(februaryColor).toBe('#solidBlue'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartInnerPadding.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartInnerPadding.test.ts new file mode 100644 index 0000000000..2bdd86519e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartInnerPadding.test.ts @@ -0,0 +1,146 @@ +import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding'; +import { BarChartLayout } from '~/generated/graphql'; + +describe('getBarChartInnerPadding', () => { + const defaultMargins = { top: 20, right: 20, bottom: 40, left: 60 }; + + describe('non-grouped mode', () => { + it('should return 0 when groupMode is undefined', () => { + const result = getBarChartInnerPadding({ + chartWidth: 500, + chartHeight: 300, + dataLength: 5, + keysLength: 3, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: undefined, + }); + + expect(result).toBe(0); + }); + + it('should return 0 when groupMode is stacked', () => { + const result = getBarChartInnerPadding({ + chartWidth: 500, + chartHeight: 300, + dataLength: 5, + keysLength: 3, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'stacked', + }); + + expect(result).toBe(0); + }); + }); + + describe('grouped mode with empty data', () => { + it('should return default inner padding when dataLength is 0', () => { + const result = getBarChartInnerPadding({ + chartWidth: 500, + chartHeight: 300, + dataLength: 0, + keysLength: 3, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING); + }); + + it('should return default inner padding when keysLength is 0', () => { + const result = getBarChartInnerPadding({ + chartWidth: 500, + chartHeight: 300, + dataLength: 5, + keysLength: 0, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING); + }); + }); + + describe('grouped mode with vertical layout', () => { + it('should calculate inner padding based on available horizontal space', () => { + const result = getBarChartInnerPadding({ + chartWidth: 800, + chartHeight: 400, + dataLength: 5, + keysLength: 2, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should return default padding when there is enough space', () => { + const result = getBarChartInnerPadding({ + chartWidth: 1000, + chartHeight: 400, + dataLength: 3, + keysLength: 2, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING); + }); + }); + + describe('grouped mode with horizontal layout', () => { + it('should calculate inner padding based on available vertical space', () => { + const result = getBarChartInnerPadding({ + chartWidth: 500, + chartHeight: 600, + dataLength: 5, + keysLength: 2, + layout: BarChartLayout.HORIZONTAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBeGreaterThanOrEqual(0); + }); + }); + + describe('tight space constraints', () => { + it('should reduce padding when space per bar is limited', () => { + const result = getBarChartInnerPadding({ + chartWidth: 200, + chartHeight: 300, + dataLength: 10, + keysLength: 5, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBeLessThanOrEqual( + BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING, + ); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should not return negative padding', () => { + const result = getBarChartInnerPadding({ + chartWidth: 100, + chartHeight: 100, + dataLength: 50, + keysLength: 10, + layout: BarChartLayout.VERTICAL, + margins: defaultMargins, + groupMode: 'grouped', + }); + + expect(result).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartQueryLimit.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartQueryLimit.test.ts deleted file mode 100644 index cd9b92978d..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getBarChartQueryLimit.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups'; -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { - type BarChartConfiguration, - BarChartGroupMode, -} from '~/generated-metadata/graphql'; -import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit'; - -describe('getBarChartQueryLimit', () => { - it('should return one-dimensional limit for bar chart without secondary axis', () => { - const result = getBarChartQueryLimit({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: null, - groupMode: BarChartGroupMode.STACKED, - } as BarChartConfiguration); - - expect(result).toBe( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); - - it('should return two-dimensional stacked limit for bar chart with secondary axis and stacked mode', () => { - const result = getBarChartQueryLimit({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - groupMode: BarChartGroupMode.STACKED, - } as BarChartConfiguration); - - expect(result).toBe( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS * - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); - - it('should return one-dimensional limit for bar chart with secondary axis and grouped mode', () => { - const result = getBarChartQueryLimit({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - groupMode: BarChartGroupMode.GROUPED, - } as BarChartConfiguration); - - expect(result).toBe( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getEffectiveGroupMode.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getEffectiveGroupMode.test.ts new file mode 100644 index 0000000000..8634c5be7c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/getEffectiveGroupMode.test.ts @@ -0,0 +1,42 @@ +import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; +import { BarChartGroupMode } from '~/generated/graphql'; + +describe('getEffectiveGroupMode', () => { + describe('without secondary axis grouping', () => { + it('should return undefined when hasGroupByOnSecondaryAxis is false', () => { + expect( + getEffectiveGroupMode(BarChartGroupMode.GROUPED, false), + ).toBeUndefined(); + }); + + it('should return undefined regardless of groupMode when no secondary axis', () => { + expect( + getEffectiveGroupMode(BarChartGroupMode.STACKED, false), + ).toBeUndefined(); + expect(getEffectiveGroupMode(null, false)).toBeUndefined(); + expect(getEffectiveGroupMode(undefined, false)).toBeUndefined(); + }); + }); + + describe('with secondary axis grouping', () => { + it('should return "grouped" when groupMode is GROUPED', () => { + expect(getEffectiveGroupMode(BarChartGroupMode.GROUPED, true)).toBe( + 'grouped', + ); + }); + + it('should return "stacked" when groupMode is STACKED', () => { + expect(getEffectiveGroupMode(BarChartGroupMode.STACKED, true)).toBe( + 'stacked', + ); + }); + + it('should return "stacked" when groupMode is null', () => { + expect(getEffectiveGroupMode(null, true)).toBe('stacked'); + }); + + it('should return "stacked" when groupMode is undefined', () => { + expect(getEffectiveGroupMode(undefined, true)).toBe('stacked'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts deleted file mode 100644 index fe1918276f..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { GraphOrderBy } from '~/generated/graphql'; -import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum'; - -describe('sortBarChartDataBySecondaryDimensionSum', () => { - const mockData = [ - { city: 'Paris', Open: 5, Closed: 10 }, - { city: 'London', Open: 20, Closed: 2 }, - { city: 'Berlin', Open: 8, Closed: 7 }, - ]; - - const keys = ['Open', 'Closed']; - - describe('VALUE_DESC sorting', () => { - it('should sort by totals in descending order', () => { - const result = sortBarChartDataBySecondaryDimensionSum({ - data: mockData, - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual([ - { city: 'London', Open: 20, Closed: 2 }, - { city: 'Paris', Open: 5, Closed: 10 }, - { city: 'Berlin', Open: 8, Closed: 7 }, - ]); - }); - }); - - describe('VALUE_ASC sorting', () => { - it('should sort by totals in ascending order', () => { - const result = sortBarChartDataBySecondaryDimensionSum({ - data: mockData, - keys, - orderBy: GraphOrderBy.VALUE_ASC, - }); - - expect(result).toEqual([ - { city: 'Paris', Open: 5, Closed: 10 }, - { city: 'Berlin', Open: 8, Closed: 7 }, - { city: 'London', Open: 20, Closed: 2 }, - ]); - }); - }); - - describe('FIELD_ASC/FIELD_DESC (non-value sorting)', () => { - it('should not sort when orderBy is FIELD_ASC', () => { - const result = sortBarChartDataBySecondaryDimensionSum({ - data: mockData, - keys, - orderBy: GraphOrderBy.FIELD_ASC, - }); - - expect(result).toEqual(mockData); - }); - - it('should not sort when orderBy is FIELD_DESC', () => { - const result = sortBarChartDataBySecondaryDimensionSum({ - data: mockData, - keys, - orderBy: GraphOrderBy.FIELD_DESC, - }); - - expect(result).toEqual(mockData); - }); - }); - - describe('edge cases', () => { - it('should handle data with missing keys in some items', () => { - const dataWithMissing = [ - { city: 'Paris', Open: 5 } as any, - { city: 'London', Open: 20, Closed: 2 }, - ]; - - const result = sortBarChartDataBySecondaryDimensionSum({ - data: dataWithMissing, - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual([ - { city: 'London', Open: 20, Closed: 2 }, - { city: 'Paris', Open: 5 }, - ]); - }); - - it('should handle data with zero values', () => { - const dataWithZeros = [ - { city: 'Paris', Open: 0, Closed: 10 }, - { city: 'London', Open: 20, Closed: 0 }, - ]; - - const result = sortBarChartDataBySecondaryDimensionSum({ - data: dataWithZeros, - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual([ - { city: 'London', Open: 20, Closed: 0 }, - { city: 'Paris', Open: 0, Closed: 10 }, - ]); - }); - - it('should handle empty data array', () => { - const result = sortBarChartDataBySecondaryDimensionSum({ - data: [], - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual([]); - }); - - it('should handle single item', () => { - const singleItem = [{ city: 'Paris', Open: 5, Closed: 10 }]; - - const result = sortBarChartDataBySecondaryDimensionSum({ - data: singleItem, - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual(singleItem); - }); - }); - - describe('stability with equal totals', () => { - it('should maintain original order for items with equal totals', () => { - const dataWithEqualTotals = [ - { city: 'Paris', Open: 10, Closed: 5 }, - { city: 'Berlin', Open: 7, Closed: 8 }, - { city: 'Madrid', Open: 9, Closed: 6 }, - ]; - - const result = sortBarChartDataBySecondaryDimensionSum({ - data: dataWithEqualTotals, - keys, - orderBy: GraphOrderBy.VALUE_DESC, - }); - - expect(result).toEqual(dataWithEqualTotals); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts deleted file mode 100644 index 46608b937f..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; -import { - FieldMetadataType, - FirstDayOfTheWeek, - ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; -import { - AxisNameDisplay, - BarChartLayout, - WidgetConfigurationType, -} from '~/generated/graphql'; - -jest.mock( - '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData', - () => ({ - fillDateGapsInBarChartData: jest.fn(({ data }) => ({ - data, - wasTruncated: true, - })), - }), -); - -jest.mock( - '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData', - () => ({ - transformOneDimensionalGroupByToBarChartData: jest.fn(() => ({ - data: [], - indexBy: 'x', - keys: ['value'], - series: [], - hasTooManyGroups: false, - formattedToRawLookup: new Map(), - })), - }), -); - -jest.mock('@/page-layout/widgets/graph/utils/filterGroupByResults', () => ({ - filterGroupByResults: jest.fn((args) => args.rawResults), -})); - -const { fillDateGapsInBarChartData } = jest.requireMock( - '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData', -) as { fillDateGapsInBarChartData: jest.Mock }; - -describe('transformGroupByDataToBarChartData', () => { - const userTimezone = 'Europe/Paris'; - - it('fills date gaps when grouping by a relation date subfield with granularity', () => { - const groupByField = { - id: 'group-by-field', - name: 'company', - type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, - }; - - const aggregateField = { - id: 'aggregate-field', - name: 'count', - type: FieldMetadataType.NUMBER, - }; - - const objectMetadataItem = { - id: 'obj-1', - nameSingular: 'company', - namePlural: 'companies', - fields: [ - groupByField, - aggregateField, - { id: 'createdAt', name: 'createdAt', type: FieldMetadataType.DATE }, - ], - } as any; - - const objectMetadataItems = [objectMetadataItem]; - - const configuration = { - __typename: 'BarChartConfiguration', - aggregateFieldMetadataId: aggregateField.id, - aggregateOperation: 'COUNT', - configurationType: WidgetConfigurationType.BAR_CHART, - layout: BarChartLayout.VERTICAL, - primaryAxisGroupByFieldMetadataId: groupByField.id, - primaryAxisGroupBySubFieldName: 'createdAt', - primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - axisNameDisplay: AxisNameDisplay.BOTH, - } as any; - - const groupByData = { - companiesGroupBy: [ - { groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], COUNT: 1 }, - ], - }; - - fillDateGapsInBarChartData.mockClear(); - - const result = transformGroupByDataToBarChartData({ - groupByData, - objectMetadataItem, - objectMetadataItems, - configuration, - aggregateOperation: 'COUNT', - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(fillDateGapsInBarChartData).toHaveBeenCalledTimes(1); - expect(fillDateGapsInBarChartData).toHaveBeenCalledWith( - expect.objectContaining({ - dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - hasSecondDimension: false, - }), - ); - expect(result.hasTooManyGroups).toBe(true); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts deleted file mode 100644 index 30452e615c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { FirstDayOfTheWeek } from 'twenty-shared/types'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { - AggregateOperations, - BarChartLayout, - GraphOrderBy, - WidgetConfigurationType, - type BarChartConfiguration, -} from '~/generated/graphql'; -import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData'; - -describe('transformTwoDimensionalGroupByToBarChartData', () => { - const userTimezone = 'Europe/Paris'; - - const mockGroupByFieldX = { - id: 'field-x', - name: 'createdAt', - type: FieldMetadataType.DATE, - label: 'Created At', - } as FieldMetadataItem; - - const mockGroupByFieldY = { - id: 'field-y', - name: 'stage', - type: FieldMetadataType.SELECT, - label: 'Stage', - } as FieldMetadataItem; - - const mockAggregateField = { - id: 'field-aggregate', - name: 'amount', - type: FieldMetadataType.NUMBER, - label: 'Amount', - } as FieldMetadataItem; - - const mockObjectMetadataItem = { - id: 'object-1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [mockGroupByFieldX, mockGroupByFieldY, mockAggregateField], - } as ObjectMetadataItem; - - const mockConfiguration: BarChartConfiguration = { - __typename: 'BarChartConfiguration', - configurationType: WidgetConfigurationType.BAR_CHART, - layout: BarChartLayout.VERTICAL, - aggregateFieldMetadataId: 'field-aggregate', - aggregateOperation: AggregateOperations.SUM, - primaryAxisGroupByFieldMetadataId: 'field-x', - secondaryAxisGroupByFieldMetadataId: 'field-y', - secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC, - }; - - it('should order keys correctly despite unordered raw results', () => { - // This test demonstrates the key ordering issue described in the user query - // Raw results where "CUSTOMER" appears before "NEW" in the data stream despite orderBy, - // bc there is no "NEW" group before october 21st and the results are primarily ordered by date ASC - // but we want them ordered alphabetically-reversed - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'SCREENING'], - sumAmount: 75000000000, - }, - { - groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'PROPOSAL'], - sumAmount: 380000000000, - }, - { - groupByDimensionValues: ['2025-10-17T00:00:00.000Z', 'CUSTOMER'], - sumAmount: 720000000000, - }, - { - groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'PROPOSAL'], - sumAmount: 580000000000, - }, - { - groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'NEW'], - sumAmount: 125000000000, - }, - ]; - - const result = transformTwoDimensionalGroupByToBarChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: mockConfiguration, - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']); - expect(result.series).toEqual([ - { key: 'SCREENING', label: 'SCREENING' }, - { key: 'PROPOSAL', label: 'PROPOSAL' }, - { key: 'NEW', label: 'NEW' }, - { key: 'CUSTOMER', label: 'CUSTOMER' }, - ]); - - expect(result.data).toHaveLength(3); - expect(result.data[0]).toEqual({ - createdAt: 'Oct 16, 2025', - SCREENING: 75000000000, - PROPOSAL: 380000000000, - }); - expect(result.data[1]).toEqual({ - createdAt: 'Oct 17, 2025', - CUSTOMER: 720000000000, - }); - expect(result.data[2]).toEqual({ - createdAt: 'Oct 21, 2025', - PROPOSAL: 580000000000, - NEW: 125000000000, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData.ts deleted file mode 100644 index 468324975c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type BarDatum } from '@nivo/bar'; -import { isNumber } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; - -type ApplyCumulativeTransformToBarChartDataOptions = { - data: BarDatum[]; - aggregateKey: string; - rangeMin?: number; - rangeMax?: number; -}; - -export const applyCumulativeTransformToBarChartData = ({ - data, - aggregateKey, - rangeMin, - rangeMax, -}: ApplyCumulativeTransformToBarChartDataOptions): BarDatum[] => { - const { result } = data.reduce<{ result: BarDatum[]; runningTotal: number }>( - (accumulator, datum) => { - const value = datum[aggregateKey]; - - if (isNumber(value)) { - accumulator.runningTotal += value; - } - - const cumulativeValue = accumulator.runningTotal; - - const isOutOfRange = - (isDefined(rangeMin) && cumulativeValue < rangeMin) || - (isDefined(rangeMax) && cumulativeValue > rangeMax); - - if (!isOutOfRange) { - accumulator.result.push({ ...datum, [aggregateKey]: cumulativeValue }); - } - - return accumulator; - }, - { result: [], runningTotal: 0 }, - ); - - return result; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts deleted file mode 100644 index 722716e2f5..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type BarDatum } from '@nivo/bar'; -import { isNumber } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; - -type ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions = { - data: BarDatum[]; - keys: string[]; - rangeMin?: number | null; - rangeMax?: number | null; -}; - -export const applyCumulativeTransformToTwoDimensionalBarChartData = ({ - data, - keys, - rangeMin, - rangeMax, -}: ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions): BarDatum[] => { - const { result } = data.reduce<{ - result: BarDatum[]; - runningTotals: Record; - }>( - (accumulator, datum) => { - const newDatum = { ...datum }; - - for (const key of keys) { - const value = datum[key]; - - if (isNumber(value)) { - accumulator.runningTotals[key] += value; - } - - newDatum[key] = accumulator.runningTotals[key]; - } - - const totalValue = keys.reduce((sum, key) => { - const value = newDatum[key]; - return sum + (typeof value === 'number' ? value : 0); - }, 0); - - const isOutOfRange = - (isDefined(rangeMin) && totalValue < rangeMin) || - (isDefined(rangeMax) && totalValue > rangeMax); - - if (!isOutOfRange) { - accumulator.result.push(newDatum); - } - - return accumulator; - }, - { - result: [], - runningTotals: Object.fromEntries(keys.map((key) => [key, 0])), - }, - ); - - return result; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts deleted file mode 100644 index f7b83241bb..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type ProcessedTwoDimensionalDataPoint } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults'; -import { type BarDatum } from '@nivo/bar'; - -type BuildTwoDimensionalBarChartDataParams = { - processedDataPoints: ProcessedTwoDimensionalDataPoint[]; - indexByKey: string; -}; - -type BuildTwoDimensionalBarChartDataResult = { - unsortedData: BarDatum[]; - yValues: Set; -}; - -export const buildTwoDimensionalBarChartData = ({ - processedDataPoints, - indexByKey, -}: BuildTwoDimensionalBarChartDataParams): BuildTwoDimensionalBarChartDataResult => { - const dataMap = new Map(); - const yValues = new Set(); - - for (const { xValue, yValue, aggregateValue } of processedDataPoints) { - yValues.add(yValue); - - if (!dataMap.has(xValue)) { - dataMap.set(xValue, { - [indexByKey]: xValue, - }); - } - - const dataItem = dataMap.get(xValue)!; - dataItem[yValue] = aggregateValue; - } - - return { - unsortedData: Array.from(dataMap.values()), - yValues, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates.ts deleted file mode 100644 index d2ee6f46e5..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type BarDatum, type ComputedBarDatum } from '@nivo/bar'; -import { BarChartLayout } from '~/generated/graphql'; - -export const calculateBarChartEndLineCoordinates = ( - bar: ComputedBarDatum, - layout: BarChartLayout, -) => { - if (layout === BarChartLayout.VERTICAL) { - return { - x1: bar.x, - x2: bar.x + bar.width, - y1: bar.y, - y2: bar.y, - }; - } - return { - x1: bar.x + bar.width, - x2: bar.x + bar.width, - y1: bar.y, - y2: bar.y + bar.height, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateMaxTickLabelLength.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateMaxTickLabelLength.ts deleted file mode 100644 index af3fa252a0..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateMaxTickLabelLength.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; - -export const calculateMaxTickLabelLength = ({ - widthPerTick, - axisFontSize, -}: { - widthPerTick: number; - axisFontSize: number; -}): number => { - const averageCharacterWidth = - axisFontSize * - COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO; - const calculatedLength = Math.floor(widthPerTick / averageCharacterWidth); - - return Math.max( - COMMON_CHART_CONSTANTS.MIN_TICK_LABEL_LENGTH, - calculatedLength, - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts index a3d94c5274..edc1a8b94a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts @@ -28,7 +28,7 @@ export const computeBarChartCategoryTickValues = ({ const values = data.map((item) => item[indexBy] as string | number); - const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout }); + const margins = getBarChartMargins({ xAxisLabel, yAxisLabel }); const totalMargins = layout === BarChartLayout.VERTICAL diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts deleted file mode 100644 index 384d323233..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { Temporal } from 'temporal-polyfill'; - -export type DimensionValue = string | Temporal.PlainDate | number | null; - -export const createEmptyDateGroup = ( - dimensionValues: DimensionValue[], - keys: string[], -): GroupByRawResult => { - const newItem: GroupByRawResult = { - groupByDimensionValues: dimensionValues.map((value) => - value instanceof Temporal.PlainDate ? value.toString() : value, - ), - }; - - for (const key of keys) { - newItem[key] = 0; - } - - return newItem; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts deleted file mode 100644 index 94ca547b9e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { fillDateGapsInOneDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData'; -import { fillDateGapsInTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData'; -import { type SupportedDateGranularity } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { type GraphOrderBy } from '~/generated/graphql'; - -type FillDateGapsParams = { - data: GroupByRawResult[]; - keys: string[]; - dateGranularity: ObjectRecordGroupByDateGranularity; - hasSecondDimension?: boolean; - orderBy?: GraphOrderBy | null; -}; - -export const fillDateGapsInBarChartData = ({ - data, - keys, - dateGranularity, - hasSecondDimension = false, - orderBy, -}: FillDateGapsParams): { data: GroupByRawResult[]; wasTruncated: boolean } => { - if (data.length === 0) { - return { data, wasTruncated: false }; - } - - if ( - BAR_CHART_CONSTANTS.DATE_GRANULARITIES_WITHOUT_GAP_FILLING.has( - dateGranularity, - ) - ) { - return { data, wasTruncated: false }; - } - - if (hasSecondDimension) { - return fillDateGapsInTwoDimensionalBarChartData({ - data, - keys, - dateGranularity: dateGranularity as SupportedDateGranularity, - orderBy, - }); - } - - return fillDateGapsInOneDimensionalBarChartData({ - data, - keys, - dateGranularity: dateGranularity as SupportedDateGranularity, - orderBy, - }); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts deleted file mode 100644 index 53ee161c50..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult'; -import { createEmptyDateGroup } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup'; -import { - getDateGroupsFromData, - type SupportedDateGranularity, -} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { Temporal } from 'temporal-polyfill'; -import { isDefined } from 'twenty-shared/utils'; -import { type GraphOrderBy } from '~/generated/graphql'; - -type OneDimensionalFillParams = { - data: GroupByRawResult[]; - keys: string[]; - dateGranularity: SupportedDateGranularity; - orderBy?: GraphOrderBy | null; -}; - -// TODO: should handle DATE and DATE_TIME here -export const fillDateGapsInOneDimensionalBarChartData = ({ - data, - keys, - dateGranularity, - orderBy, -}: OneDimensionalFillParams): FillDateGapsResult => { - const existingDateGroupsMap = new Map(); - 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 { dates: allDates, wasTruncated } = getDateGroupsFromData({ - parsedDates, - dateGranularity, - orderBy, - }); - - const filledData = allDates.map((date) => { - const key = date.toString(); - const existingDateGroup = existingDateGroupsMap.get(key); - - return isDefined(existingDateGroup) - ? existingDateGroup - : createEmptyDateGroup([date], keys); - }); - - return { data: filledData, wasTruncated }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts deleted file mode 100644 index 03ca1476ab..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult'; -import { - createEmptyDateGroup, - type DimensionValue, -} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup'; -import { - getDateGroupsFromData, - type SupportedDateGranularity, -} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { Temporal } from 'temporal-polyfill'; -import { isDefined } from 'twenty-shared/utils'; -import { type GraphOrderBy } from '~/generated/graphql'; - -type TwoDimensionalFillParams = { - data: GroupByRawResult[]; - keys: string[]; - dateGranularity: SupportedDateGranularity; - orderBy?: GraphOrderBy | null; -}; - -export const fillDateGapsInTwoDimensionalBarChartData = ({ - data, - keys, - dateGranularity, - orderBy, -}: TwoDimensionalFillParams): FillDateGapsResult => { - const existingDateGroupsMap = new Map(); - const parsedDates: Temporal.PlainDate[] = []; - const uniqueSecondDimensionValues = new Set(); - - 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) as DimensionValue; - uniqueSecondDimensionValues.add(secondDimensionValue); - - const key = `${parsedDate.toString()}_${String(secondDimensionValue)}`; - existingDateGroupsMap.set(key, item); - } - - if (parsedDates.length === 0) { - return { data, wasTruncated: false }; - } - - const { dates: allDates, wasTruncated } = getDateGroupsFromData({ - parsedDates, - dateGranularity, - orderBy, - }); - - const filledData = allDates.flatMap((date) => - Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => { - const key = `${date.toString()}_${String(secondDimensionValue)}`; - const existingDateGroup = existingDateGroupsMap.get(key); - - return isDefined(existingDateGroup) - ? existingDateGroup - : createEmptyDateGroup([date, secondDimensionValue], keys); - }), - ); - - return { data: filledData, wasTruncated }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts index 844c46cebb..3513eb0d5b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs.ts @@ -40,7 +40,7 @@ export const getBarChartAxisConfigs = ({ ? valueTickValues : numberOfValueTicks; - const baseMargins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout }); + const baseMargins = getBarChartMargins({ xAxisLabel, yAxisLabel }); const hasRotation = bottomAxisTickRotation !== 0; const margins = @@ -96,7 +96,7 @@ export const getBarChartAxisConfigs = ({ legendPosition: 'middle' as const, tickRotation: BAR_CHART_CONSTANTS.NO_ROTATION_ANGLE, tickValues: resolvedValueTickValues, - legend: yAxisLabel, + legend: xAxisLabel, legendOffset: BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET, format: (value: number) => truncateTickLabel( @@ -110,7 +110,7 @@ export const getBarChartAxisConfigs = ({ legendPosition: 'middle' as const, tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, tickValues: categoryTickValues, - legend: xAxisLabel, + legend: yAxisLabel, legendOffset: -margins.left + BAR_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING, format: (value: string | number) => diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts index c28de17ee9..6764bfbf33 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins.ts @@ -1,6 +1,5 @@ import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; import { isDefined } from 'twenty-shared/utils'; -import { BarChartLayout } from '~/generated/graphql'; const BAR_CHART_MARGINS = { top: COMMON_CHART_CONSTANTS.MARGIN_TOP, @@ -33,26 +32,20 @@ const BAR_CHART_MARGINS_WITH_Y_LABEL = { export const getBarChartMargins = ({ xAxisLabel, yAxisLabel, - layout, }: { xAxisLabel?: string; yAxisLabel?: string; - layout: BarChartLayout; }) => { if (isDefined(xAxisLabel) && isDefined(yAxisLabel)) { return BAR_CHART_MARGINS_WITH_BOTH_LABELS; } if (isDefined(xAxisLabel)) { - return layout === BarChartLayout.HORIZONTAL - ? BAR_CHART_MARGINS_WITH_Y_LABEL - : BAR_CHART_MARGINS_WITH_X_LABEL; + return BAR_CHART_MARGINS_WITH_X_LABEL; } if (isDefined(yAxisLabel)) { - return layout === BarChartLayout.HORIZONTAL - ? BAR_CHART_MARGINS_WITH_X_LABEL - : BAR_CHART_MARGINS_WITH_Y_LABEL; + return BAR_CHART_MARGINS_WITH_Y_LABEL; } return BAR_CHART_MARGINS; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit.ts deleted file mode 100644 index 0c89c561a1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups'; -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { isChartConfigurationTwoDimensional } from '@/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional'; -import { - BarChartGroupMode, - type BarChartConfiguration, -} from '~/generated/graphql'; - -export const getBarChartQueryLimit = ( - configuration: BarChartConfiguration, -): number => { - if ( - isChartConfigurationTwoDimensional(configuration) && - configuration.groupMode === BarChartGroupMode.STACKED - ) { - return ( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS * - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS - ); - } - - return ( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts index 841ff55480..003cdf597c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTickConfig.ts @@ -51,7 +51,7 @@ export const getBarChartTickConfig = ({ layout, }); - const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout }); + const margins = getBarChartMargins({ xAxisLabel, yAxisLabel }); const availableWidth = width - (margins.left + margins.right); const availableHeight = height - (margins.top + margins.bottom); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts deleted file mode 100644 index 00057c1043..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange'; -import { type Temporal } from 'temporal-polyfill'; -import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { isDefined, sortPlainDate } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; - -export type SupportedDateGranularity = - | ObjectRecordGroupByDateGranularity.DAY - | ObjectRecordGroupByDateGranularity.MONTH - | ObjectRecordGroupByDateGranularity.QUARTER - | ObjectRecordGroupByDateGranularity.YEAR - | ObjectRecordGroupByDateGranularity.WEEK; - -type GetDateGroupsFromDataParams = { - parsedDates: Temporal.PlainDate[]; - dateGranularity: SupportedDateGranularity; - orderBy?: GraphOrderBy | null; -}; - -export const getDateGroupsFromData = ({ - parsedDates, - dateGranularity, - orderBy, -}: GetDateGroupsFromDataParams): { - dates: Temporal.PlainDate[]; - wasTruncated: boolean; -} => { - const sortedPlainDates = parsedDates.toSorted(sortPlainDate('asc')); - - const minDate = sortedPlainDates.at(0); - const maxDate = sortedPlainDates.at(-1); - - if (!isDefined(minDate) || !isDefined(maxDate)) { - return { dates: [], wasTruncated: false }; - } - - const result = generateDateGroupsInRange({ - startDate: minDate, - endDate: maxDate, - granularity: dateGranularity, - }); - - const dates = - orderBy === GraphOrderBy.FIELD_DESC - ? result.dates.toReversed() - : result.dates; - - return { dates, wasTruncated: result.wasTruncated }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts deleted file mode 100644 index edee0a3e3d..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { type BarDatum } from '@nivo/bar'; -import { BarChartGroupMode } from '~/generated/graphql'; - -type LimitTwoDimensionalBarChartDataParams = { - sortedData: BarDatum[]; - sortedKeys: string[]; - sortedSeries: BarChartSeries[]; - groupMode?: BarChartGroupMode | null; -}; - -type LimitTwoDimensionalBarChartDataResult = { - limitedData: BarDatum[]; - limitedKeys: string[]; - limitedSeries: BarChartSeries[]; - hasTooManyGroups: boolean; -}; - -// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) -export const limitTwoDimensionalBarChartData = ({ - sortedData, - sortedKeys, - sortedSeries, - groupMode, -}: LimitTwoDimensionalBarChartDataParams): LimitTwoDimensionalBarChartDataResult => { - const effectiveGroupMode = groupMode ?? BarChartGroupMode.STACKED; - - const hasTooManyBars = - sortedData.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS; - const hasTooManyGroups = - sortedKeys.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR; - - const limitedData = sortedData.slice( - 0, - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS, - ); - const limitedKeys = sortedKeys.slice( - 0, - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR, - ); - const limitedSeries = sortedSeries.slice( - 0, - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR, - ); - - if (effectiveGroupMode === BarChartGroupMode.STACKED) { - return { - limitedData, - limitedKeys, - limitedSeries, - hasTooManyGroups: hasTooManyBars || hasTooManyGroups, - }; - } - - const totalSegments = limitedData.length * limitedKeys.length; - const hasTooManySegments = - totalSegments > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS; - - if (!hasTooManySegments) { - return { - limitedData, - limitedKeys, - limitedSeries, - hasTooManyGroups: hasTooManyBars || hasTooManyGroups, - }; - } - - const maxXValues = Math.floor( - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS / limitedKeys.length, - ); - const furtherLimitedData = limitedData.slice(0, Math.max(1, maxXValues)); - - return { - limitedData: furtherLimitedData, - limitedKeys, - limitedSeries, - hasTooManyGroups: true, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts deleted file mode 100644 index e345fe7df4..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type BarDatum } from '@nivo/bar'; -import { isDefined } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; - -type SortBarChartDataBySecondaryDimensionSumParams = { - data: BarDatum[]; - keys: string[]; - orderBy: GraphOrderBy; -}; - -export const sortBarChartDataBySecondaryDimensionSum = ({ - data, - keys, - orderBy, -}: SortBarChartDataBySecondaryDimensionSumParams): BarDatum[] => { - if ( - orderBy !== GraphOrderBy.VALUE_ASC && - orderBy !== GraphOrderBy.VALUE_DESC - ) { - return data; - } - - const dataWithSecondaryDimensionSums = data.map((datum) => { - const secondaryDimensionSum = keys.reduce((sumAccumulator, segmentKey) => { - const segmentValue = datum[segmentKey]; - - if (isDefined(segmentValue) && typeof segmentValue === 'number') { - return sumAccumulator + segmentValue; - } - - return sumAccumulator; - }, 0); - - return { datum, secondaryDimensionSum }; - }); - - dataWithSecondaryDimensionSums.sort((a, b) => { - if (orderBy === GraphOrderBy.VALUE_ASC) { - return a.secondaryDimensionSum - b.secondaryDimensionSum; - } else { - return b.secondaryDimensionSum - a.secondaryDimensionSum; - } - }); - - return dataWithSecondaryDimensionSums.map(({ datum }) => datum); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts deleted file mode 100644 index 16fe488d26..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData'; -import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded'; -import { type BarDatum } from '@nivo/bar'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { type FieldMetadataType } from '~/generated-metadata/graphql'; -import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql'; - -type SortTwoDimensionalBarChartDataConfiguration = { - data: BarDatum[]; - keys: string[]; - indexByKey: string; - configuration: BarChartConfiguration; - primaryAxisFormattedToRawLookup: Map; - primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null; - secondaryAxisFormattedToRawLookup?: Map; - secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null; - secondaryAxisFieldType?: FieldMetadataType; - secondaryAxisSubFieldName?: CompositeFieldSubFieldName; -}; - -type SortTwoDimensionalBarChartDataResult = { - sortedData: BarDatum[]; - sortedKeys: string[]; - sortedSeries: BarChartSeries[]; - colorMode: GraphColorMode; -}; - -export const sortTwoDimensionalBarChartData = ({ - data, - keys, - indexByKey, - configuration: { - primaryAxisOrderBy, - primaryAxisManualSortOrder, - secondaryAxisOrderBy, - secondaryAxisManualSortOrder, - color, - }, - primaryAxisFormattedToRawLookup, - primaryAxisSelectFieldOptions, - secondaryAxisFormattedToRawLookup, - secondaryAxisSelectFieldOptions, - secondaryAxisFieldType, - secondaryAxisSubFieldName, -}: SortTwoDimensionalBarChartDataConfiguration): SortTwoDimensionalBarChartDataResult => { - const sortedKeys = sortSecondaryAxisData({ - items: keys, - orderBy: secondaryAxisOrderBy, - manualSortOrder: secondaryAxisManualSortOrder, - formattedToRawLookup: secondaryAxisFormattedToRawLookup, - selectFieldOptions: secondaryAxisSelectFieldOptions, - getFormattedValue: (item) => item, - fieldType: secondaryAxisFieldType, - subFieldName: secondaryAxisSubFieldName, - }); - - const sortedSeries: BarChartSeries[] = sortedKeys.map((key) => { - const rawValue = secondaryAxisFormattedToRawLookup?.get(key); - - return { - key, - label: key, - color: determineChartItemColor({ - configurationColor: parseGraphColor(color), - selectOptions: secondaryAxisSelectFieldOptions, - rawValue: isDefined(rawValue) ? String(rawValue) : key, - }), - }; - }); - - let sortedData: BarDatum[] = data; - - if (isDefined(primaryAxisOrderBy)) { - if ( - primaryAxisOrderBy === GraphOrderBy.VALUE_ASC || - primaryAxisOrderBy === GraphOrderBy.VALUE_DESC - ) { - sortedData = sortBarChartDataBySecondaryDimensionSum({ - data, - keys: sortedKeys, - orderBy: primaryAxisOrderBy, - }); - } else { - sortedData = - sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({ - data, - orderBy: primaryAxisOrderBy, - manualSortOrder: primaryAxisManualSortOrder, - formattedToRawLookup: primaryAxisFormattedToRawLookup, - getFormattedValue: (datum) => datum[indexByKey] as string, - selectFieldOptions: primaryAxisSelectFieldOptions, - }); - } - } - - const colorMode = determineGraphColorMode({ - configurationColor: color, - selectFieldOptions: secondaryAxisSelectFieldOptions, - }); - - return { - sortedData, - sortedKeys, - sortedSeries, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts deleted file mode 100644 index d0a9f9ad8c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData'; -import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; -import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData'; -import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; -import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; -import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { type BarDatum } from '@nivo/bar'; -import { - isDefined, - isFieldMetadataDateKind, - type FirstDayOfTheWeek, -} from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { - AxisNameDisplay, - BarChartLayout, - type BarChartConfiguration, -} from '~/generated/graphql'; - -type TransformGroupByDataToBarChartDataParams = { - groupByData: Record | null | undefined; - objectMetadataItem: ObjectMetadataItem; - objectMetadataItems: ObjectMetadataItem[]; - configuration: BarChartConfiguration; - aggregateOperation: string; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformGroupByDataToBarChartDataResult = { - data: BarDatum[]; - indexBy: string; - keys: string[]; - series: BarChartSeries[]; - xAxisLabel?: string; - yAxisLabel?: string; - showDataLabels: boolean; - showLegend: boolean; - layout?: BarChartLayout; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -const EMPTY_BAR_CHART_RESULT: Omit< - TransformGroupByDataToBarChartDataResult, - 'xAxisLabel' | 'yAxisLabel' -> = { - data: [], - indexBy: '', - keys: [], - series: [], - showDataLabels: false, - showLegend: true, - layout: BarChartLayout.VERTICAL, - hasTooManyGroups: false, - formattedToRawLookup: new Map(), - colorMode: 'automaticPalette', -}; - -export const transformGroupByDataToBarChartData = ({ - groupByData, - objectMetadataItem, - objectMetadataItems, - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek, -}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => { - const groupByFieldX = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.primaryAxisGroupByFieldMetadataId, - ); - - const groupByFieldY = isDefined( - configuration.secondaryAxisGroupByFieldMetadataId, - ) - ? objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.secondaryAxisGroupByFieldMetadataId, - ) - : undefined; - - const aggregateField = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.aggregateFieldMetadataId, - ); - - const queryResultGqlFieldName = - getGroupByQueryResultGqlFieldName(objectMetadataItem); - const rawResults = groupByData?.[queryResultGqlFieldName]; - - const layout = - configuration.layout === BarChartLayout.HORIZONTAL - ? BarChartLayout.HORIZONTAL - : BarChartLayout.VERTICAL; - - const isHorizontal = layout === BarChartLayout.HORIZONTAL; - - const showCategoryLabel = isHorizontal - ? configuration.axisNameDisplay === AxisNameDisplay.Y || - configuration.axisNameDisplay === AxisNameDisplay.BOTH - : configuration.axisNameDisplay === AxisNameDisplay.X || - configuration.axisNameDisplay === AxisNameDisplay.BOTH; - - const showValueLabel = isHorizontal - ? configuration.axisNameDisplay === AxisNameDisplay.X || - configuration.axisNameDisplay === AxisNameDisplay.BOTH - : configuration.axisNameDisplay === AxisNameDisplay.Y || - configuration.axisNameDisplay === AxisNameDisplay.BOTH; - - const xAxisLabel = - showCategoryLabel && isDefined(groupByFieldX) - ? groupByFieldX.label - : undefined; - - const yAxisLabel = - showValueLabel && isDefined(aggregateField) - ? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}` - : undefined; - - if (!isDefined(groupByData)) { - return { - ...EMPTY_BAR_CHART_RESULT, - xAxisLabel, - yAxisLabel, - layout, - }; - } - - if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) { - return { - ...EMPTY_BAR_CHART_RESULT, - xAxisLabel, - yAxisLabel, - layout, - }; - } - - const primaryAxisSubFieldName = - configuration.primaryAxisGroupBySubFieldName ?? undefined; - const secondaryAxisSubFieldName = - configuration.secondaryAxisGroupBySubFieldName ?? undefined; - - const indexByKey = getFieldKey({ - field: groupByFieldX, - subFieldName: primaryAxisSubFieldName, - }); - - if (!isDefined(rawResults) || !Array.isArray(rawResults)) { - return { - ...EMPTY_BAR_CHART_RESULT, - indexBy: indexByKey, - xAxisLabel, - yAxisLabel, - layout, - }; - } - - const filteredResults = filterGroupByResults({ - rawResults, - filterOptions: { - rangeMin: configuration.isCumulative - ? undefined - : (configuration.rangeMin ?? undefined), - rangeMax: configuration.isCumulative - ? undefined - : (configuration.rangeMax ?? undefined), - omitNullValues: configuration.omitNullValues ?? false, - }, - aggregateField, - aggregateOperation: - configuration.aggregateOperation as unknown as ExtendedAggregateOperations, - aggregateOperationFromRawResult: aggregateOperation, - objectMetadataItem, - }); - - const showDataLabels = configuration.displayDataLabel ?? false; - const showLegend = configuration.displayLegend ?? true; - - const isDateField = isFieldMetadataDateKind(groupByFieldX.type); - const isNestedDateField = isRelationNestedFieldDateKind({ - relationField: groupByFieldX, - relationNestedFieldName: primaryAxisSubFieldName, - objectMetadataItems, - }); - - const primaryAxisDateGranularity = - isDateField || isNestedDateField - ? (configuration.primaryAxisDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined; - - const isSecondaryDateField = isDefined(groupByFieldY) - ? isFieldMetadataDateKind(groupByFieldY.type) - : false; - - const isSecondaryNestedDateField = - isDefined(groupByFieldY) && - isRelationNestedFieldDateKind({ - relationField: groupByFieldY, - relationNestedFieldName: secondaryAxisSubFieldName, - objectMetadataItems, - }); - - const secondaryAxisDateGranularity = - isSecondaryDateField || isSecondaryNestedDateField - ? (configuration.secondaryAxisGroupByDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined; - - const sanitizedConfiguration: BarChartConfiguration = { - ...configuration, - primaryAxisDateGranularity: primaryAxisDateGranularity ?? undefined, - secondaryAxisGroupByDateGranularity: - secondaryAxisDateGranularity ?? undefined, - }; - - const shouldApplyDateGapFill = isDefined(primaryAxisDateGranularity); - - const omitNullValues = configuration.omitNullValues ?? false; - - const dateGapFillResult = - shouldApplyDateGapFill && !omitNullValues - ? fillDateGapsInBarChartData({ - data: filteredResults, - keys: [aggregateField.name], - dateGranularity: - primaryAxisDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, - hasSecondDimension: isDefined(groupByFieldY), - orderBy: configuration.primaryAxisOrderBy, - }) - : { data: filteredResults, wasTruncated: false }; - - const filteredResultsWithDateGaps = dateGapFillResult.data; - const dateRangeWasTruncated = dateGapFillResult.wasTruncated; - - const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT; - const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues; - - const resultsWithAllGapsFilled = shouldApplySelectGapFill - ? fillSelectGapsInChartData({ - data: filteredResultsWithDateGaps, - selectOptions: groupByFieldX.options, - aggregateKeys: [aggregateField.name], - hasSecondDimension: isDefined(groupByFieldY), - }) - : filteredResultsWithDateGaps; - - const baseResult = isDefined(groupByFieldY) - ? transformTwoDimensionalGroupByToBarChartData({ - rawResults: resultsWithAllGapsFilled, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration: sanitizedConfiguration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }) - : transformOneDimensionalGroupByToBarChartData({ - rawResults: resultsWithAllGapsFilled, - groupByFieldX, - aggregateField, - configuration: sanitizedConfiguration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - return { - ...baseResult, - xAxisLabel, - yAxisLabel, - showDataLabels, - showLegend, - layout, - hasTooManyGroups: baseResult.hasTooManyGroups || dateRangeWasTruncated, - formattedToRawLookup: baseResult.formattedToRawLookup, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts deleted file mode 100644 index 9f7f7139f6..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; -import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults'; -import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded'; -import { type BarDatum } from '@nivo/bar'; -import { - isDefined, - isFieldMetadataSelectKind, - type FirstDayOfTheWeek, -} from 'twenty-shared/utils'; -import { type BarChartConfiguration } from '~/generated/graphql'; - -type TransformOneDimensionalGroupByToBarChartDataParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: BarChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformOneDimensionalGroupByToBarChartDataResult = { - data: BarDatum[]; - indexBy: string; - keys: string[]; - series: BarChartSeries[]; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -export const transformOneDimensionalGroupByToBarChartData = ({ - rawResults, - groupByFieldX, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => { - const indexByKey = getFieldKey({ - field: groupByFieldX, - subFieldName: primaryAxisSubFieldName ?? undefined, - }); - - const aggregateValueKey = - indexByKey === aggregateField.name - ? `${aggregateField.name}-aggregate` - : aggregateField.name; - - const { processedDataPoints, formattedToRawLookup } = - processOneDimensionalGroupByResults({ - rawResults, - groupByFieldX, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - const unsortedData: BarDatum[] = processedDataPoints.map( - ({ xValue, rawXValue, aggregateValue }) => { - const color = determineChartItemColor({ - configurationColor: parseGraphColor(configuration.color), - selectOptions: isFieldMetadataSelectKind(groupByFieldX.type) - ? groupByFieldX.options - : undefined, - rawValue: isDefined(rawXValue) ? String(rawXValue) : null, - }); - - return { - [indexByKey]: xValue, - [aggregateValueKey]: aggregateValue, - ...(isDefined(color) && { color }), - }; - }, - ); - - const sortedData = sortChartDataIfNeeded({ - data: unsortedData, - orderBy: configuration.primaryAxisOrderBy, - manualSortOrder: configuration.primaryAxisManualSortOrder, - formattedToRawLookup, - getFieldValue: (datum) => datum[indexByKey] as string, - getNumericValue: (datum) => datum[aggregateValueKey] as number, - selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type) - ? groupByFieldX.options - : undefined, - }); - - const limitedSortedData = sortedData.slice( - 0, - BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS, - ); - - const series: BarChartSeries[] = [ - { - key: aggregateValueKey, - label: aggregateField.label, - }, - ]; - - const finalData = configuration.isCumulative - ? applyCumulativeTransformToBarChartData({ - data: limitedSortedData, - aggregateKey: aggregateValueKey, - rangeMin: configuration.rangeMin ?? undefined, - rangeMax: configuration.rangeMax ?? undefined, - }) - : limitedSortedData; - - const colorMode = determineGraphColorMode({ - configurationColor: configuration.color, - selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type) - ? groupByFieldX.options - : undefined, - }); - - return { - data: finalData, - indexBy: indexByKey, - keys: [aggregateValueKey], - series, - hasTooManyGroups: - rawResults.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS, - formattedToRawLookup, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts deleted file mode 100644 index 1563e1025d..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData'; -import { buildTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData'; -import { limitTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData'; -import { sortTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; -import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults'; -import { type BarDatum } from '@nivo/bar'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; -import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; -import { type BarChartConfiguration } from '~/generated/graphql'; - -type TransformTwoDimensionalGroupByToBarChartDataParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - groupByFieldY: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: BarChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformTwoDimensionalGroupByToBarChartDataResult = { - data: BarDatum[]; - indexBy: string; - keys: string[]; - series: BarChartSeries[]; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -export const transformTwoDimensionalGroupByToBarChartData = ({ - rawResults, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => { - const indexByKey = getFieldKey({ - field: groupByFieldX, - subFieldName: primaryAxisSubFieldName ?? undefined, - }); - - const { processedDataPoints, formattedToRawLookup, yFormattedToRawLookup } = - processTwoDimensionalGroupByResults({ - rawResults, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - const { unsortedData, yValues } = buildTwoDimensionalBarChartData({ - processedDataPoints, - indexByKey, - }); - - const { sortedData, sortedKeys, sortedSeries, colorMode } = - sortTwoDimensionalBarChartData({ - data: unsortedData, - keys: Array.from(yValues), - indexByKey, - configuration, - primaryAxisFormattedToRawLookup: formattedToRawLookup, - primaryAxisSelectFieldOptions: groupByFieldX.options, - secondaryAxisFormattedToRawLookup: yFormattedToRawLookup, - secondaryAxisSelectFieldOptions: groupByFieldY.options, - secondaryAxisFieldType: groupByFieldY.type, - secondaryAxisSubFieldName: - (configuration.secondaryAxisGroupBySubFieldName ?? undefined) as - | CompositeFieldSubFieldName - | undefined, - }); - - const { limitedData, limitedKeys, limitedSeries, hasTooManyGroups } = - limitTwoDimensionalBarChartData({ - sortedData, - sortedKeys, - sortedSeries, - groupMode: configuration.groupMode, - }); - - const finalData = configuration.isCumulative - ? applyCumulativeTransformToTwoDimensionalBarChartData({ - data: limitedData, - keys: limitedKeys, - rangeMin: configuration.rangeMin, - rangeMax: configuration.rangeMax, - }) - : limitedData; - - return { - data: finalData, - indexBy: indexByKey, - keys: limitedKeys, - series: limitedSeries, - hasTooManyGroups, - formattedToRawLookup, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx index 27a5ac1032..c254b4bdf6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx @@ -15,7 +15,7 @@ import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineCha import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme'; import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState'; import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries'; import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig'; import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig'; @@ -49,7 +49,7 @@ type LinesLayerProps = LineCustomSvgLayerProps; type NoDataLayerWrapperProps = LineCustomSvgLayerProps; type GraphWidgetLineChartProps = { - data: LineChartSeries[]; + data: LineChartSeriesWithColor[]; showLegend?: boolean; showGrid?: boolean; enablePointLabel?: boolean; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx index e6ffc0218b..345bb36de5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx @@ -3,7 +3,6 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; import { useGraphLineChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData'; -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; import { assertLineChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertLineChartWidget'; import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; @@ -19,6 +18,7 @@ import { useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { AppPath } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { AxisNameDisplay, type LineChartDataPoint } from '~/generated/graphql'; const GraphWidgetLineChart = lazy(() => import( @@ -67,6 +67,19 @@ export const GraphWidgetLineChartRenderer = () => { ? 'stacked' : undefined; + const axisNameDisplay = configuration.axisNameDisplay; + + const showXAxis = + axisNameDisplay === AxisNameDisplay.X || + axisNameDisplay === AxisNameDisplay.BOTH; + + const showYAxis = + axisNameDisplay === AxisNameDisplay.Y || + axisNameDisplay === AxisNameDisplay.BOTH; + + const displayXAxisLabel = showXAxis ? xAxisLabel : undefined; + const displayYAxisLabel = showYAxis ? yAxisLabel : undefined; + const chartFilterKey = generateChartAggregateFilterKey( configuration.rangeMin, configuration.rangeMax, @@ -124,8 +137,8 @@ export const GraphWidgetLineChartRenderer = () => { key={chartFilterKey} id={widget.id} data={series} - xAxisLabel={xAxisLabel} - yAxisLabel={yAxisLabel} + xAxisLabel={displayXAxisLabel} + yAxisLabel={displayYAxisLabel} enablePointLabel={showDataLabels} showLegend={showLegend} rangeMin={configuration.rangeMin ?? undefined} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts index 01158be20b..407da07254 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/__tests__/useLineChartData.test.ts @@ -1,8 +1,9 @@ -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { renderHook } from '@testing-library/react'; import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData'; +import { type LineChartSeries } from '~/generated/graphql'; const mockUseRecoilComponentValue = jest.fn(); jest.mock( @@ -57,7 +58,7 @@ describe('useLineChartData', () => { }, }; - const mockData: LineChartSeries[] = [ + const mockData: LineChartSeriesWithColor[] = [ { id: 'series1', data: [ @@ -175,6 +176,7 @@ describe('useLineChartData', () => { { id: 'series1', data: [{ x: 'Jan', y: 100 }], + label: 'series1', }, ]; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts index 6ead53f3d3..47f1c62357 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts @@ -1,15 +1,23 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit'; -import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData'; -import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { LINE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/lineChartData'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { useMemo } from 'react'; -import { type LineChartConfiguration } from '~/generated/graphql'; +import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; +import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; +import { extractLineChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractLineChartDataConfiguration'; +import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; +import { useQuery } from '@apollo/client'; +import { isString } from '@sniptt/guards'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { + type LineChartConfiguration, + type LineChartDataPoint, + type LineChartSeries, +} from '~/generated/graphql'; type UseGraphLineChartWidgetDataProps = { objectMetadataItemId: string; @@ -18,18 +26,16 @@ type UseGraphLineChartWidgetDataProps = { type UseGraphLineChartWidgetDataResult = { series: LineChartSeries[]; - xAxisLabel?: string; - yAxisLabel?: string; showDataLabels: boolean; showLegend: boolean; hasTooManyGroups: boolean; formattedToRawLookup: Map; colorMode: GraphColorMode; + xAxisLabel: string; + yAxisLabel: string; loading: boolean; error?: Error; - objectMetadataItem: ReturnType< - typeof useObjectMetadataItemById - >['objectMetadataItem']; + objectMetadataItem: ObjectMetadataItem; }; export const useGraphLineChartWidgetData = ({ @@ -39,49 +45,85 @@ export const useGraphLineChartWidgetData = ({ const { objectMetadataItem } = useObjectMetadataItemById({ objectId: objectMetadataItemId, }); - const { objectMetadataItems } = useObjectMetadataItems(); - const limit = getLineChartQueryLimit(configuration); + const apolloCoreClient = useApolloCoreClient(); + + const dataConfiguration = extractLineChartDataConfiguration(configuration); const { - data: groupByData, + data: queryData, loading, error, - aggregateOperation, - } = useGraphWidgetGroupByQuery({ - objectMetadataItemId, - configuration, - limit, + } = useQuery(LINE_CHART_DATA, { + client: apolloCoreClient, + variables: { + input: { + objectMetadataId: objectMetadataItemId, + configuration: dataConfiguration, + }, + }, }); - const { userTimezone } = useUserTimezone(); - const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const formattedToRawLookup = queryData?.lineChartData?.formattedToRawLookup + ? new Map(Object.entries(queryData.lineChartData.formattedToRawLookup)) + : new Map(); - const transformedData = useMemo( - () => - transformGroupByDataToLineChartData({ - groupByData, - objectMetadataItem, - objectMetadataItems: objectMetadataItems ?? [], - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek: userFirstDayOfTheWeek, - }), - [ - groupByData, - objectMetadataItem, - objectMetadataItems, - configuration, - aggregateOperation, - userTimezone, - userFirstDayOfTheWeek, - ], + const secondaryAxisField = objectMetadataItem?.fields?.find( + (field) => field.id === configuration.secondaryAxisGroupByFieldMetadataId, + ); + + const selectFieldOptions = + isDefined(secondaryAxisField) && + (secondaryAxisField.type === FieldMetadataType.SELECT || + secondaryAxisField.type === FieldMetadataType.MULTI_SELECT) + ? secondaryAxisField.options + : null; + + const configurationColor = parseGraphColor(configuration.color); + + const colorMode = determineGraphColorMode({ + configurationColor, + selectFieldOptions, + }); + + const series = queryData?.lineChartData?.series?.map( + (seriesItem: { + id: string; + label: string; + data: Array; + }): LineChartSeriesWithColor => { + const rawValue = formattedToRawLookup.get(seriesItem.id); + + const itemColor = determineChartItemColor({ + configurationColor, + selectOptions: selectFieldOptions, + rawValue: isString(rawValue) ? rawValue : undefined, + }); + + return { + id: seriesItem.id, + label: seriesItem.label, + color: itemColor, + data: seriesItem.data.map( + (point: LineChartDataPoint): LineChartDataPoint => ({ + x: point.x, + y: point.y, + }), + ), + }; + }, ); return { - ...transformedData, + series, + showDataLabels: configuration.displayDataLabel ?? false, + showLegend: configuration.displayLegend ?? true, + hasTooManyGroups: queryData?.lineChartData?.hasTooManyGroups ?? false, + colorMode, + formattedToRawLookup, objectMetadataItem, + xAxisLabel: queryData?.lineChartData?.xAxisLabel ?? '', + yAxisLabel: queryData?.lineChartData?.yAxisLabel ?? '', loading, error, }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts index a1942ffdab..7344a79b6a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData.ts @@ -1,6 +1,6 @@ import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; @@ -10,7 +10,7 @@ import { type LineSeries } from '@nivo/line'; import { useMemo } from 'react'; type UseLineChartDataProps = { - data: LineChartSeries[]; + data: LineChartSeriesWithColor[]; colorRegistry: GraphColorRegistry; id: string; colorMode: GraphColorMode; @@ -41,9 +41,8 @@ export const useLineChartData = ({ .replace(/\s+/g, '_') .replace(/[^a-zA-Z0-9_-]/g, ''); const areaFillId = `areaFill-${id}-${sanitizedSeriesId}-${index}`; - const label = series.label ?? series.id; - return { ...series, colorScheme, areaFillId, label }; + return { ...series, colorScheme, areaFillId }; }); }, [data, colorRegistry, id, colorMode]); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries.ts index de588cf936..72b7740ab5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries.ts @@ -1,7 +1,7 @@ -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; -export type LineChartEnrichedSeries = LineChartSeries & { +export type LineChartEnrichedSeries = LineChartSeriesWithColor & { colorScheme: GraphColorScheme; areaFillId: string; label: string; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor.ts similarity index 82% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor.ts index 34d4f53e5f..cb538a0b9e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor.ts @@ -1,9 +1,9 @@ import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; -export type LineChartSeries = { +export type LineChartSeriesWithColor = { id: string; - label?: string; + label: string; color?: GraphColor; data: LineChartDataPoint[]; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap deleted file mode 100644 index 897e0e2943..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`applyCumulativeTransformToLineChartData should handle basic accumulation 1`] = ` -[ - { - "x": "Jan", - "y": 10, - }, - { - "x": "Feb", - "y": 30, - }, - { - "x": "Mar", - "y": 60, - }, -] -`; - -exports[`applyCumulativeTransformToLineChartData should handle empty data 1`] = `[]`; - -exports[`applyCumulativeTransformToLineChartData should handle filter above rangeMax 1`] = ` -[ - { - "x": "a", - "y": 10, - }, -] -`; - -exports[`applyCumulativeTransformToLineChartData should handle filter below rangeMin 1`] = ` -[ - { - "x": "b", - "y": 20, - }, - { - "x": "c", - "y": 30, - }, -] -`; - -exports[`applyCumulativeTransformToLineChartData should handle skip null y values 1`] = ` -[ - { - "x": "a", - "y": 10, - }, - { - "x": "b", - "y": 10, - }, - { - "x": "c", - "y": 30, - }, -] -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts deleted file mode 100644 index 19ea8c5537..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData'; - -describe('applyCumulativeTransformToLineChartData', () => { - const testCases = [ - { - name: 'basic accumulation', - data: [ - { x: 'Jan', y: 10 }, - { x: 'Feb', y: 20 }, - { x: 'Mar', y: 30 }, - ], - }, - { - name: 'filter below rangeMin', - data: [ - { x: 'a', y: 10 }, - { x: 'b', y: 10 }, - { x: 'c', y: 10 }, - ], - rangeMin: 15, - }, - { - name: 'filter above rangeMax', - data: [ - { x: 'a', y: 10 }, - { x: 'b', y: 20 }, - { x: 'c', y: 30 }, - ], - rangeMax: 25, - }, - { - name: 'empty data', - data: [], - }, - { - name: 'skip null y values', - data: [ - { x: 'a', y: 10 }, - { x: 'b', y: null }, - { x: 'c', y: 20 }, - ], - }, - ]; - - it.each(testCases)('should handle $name', ({ data, rangeMin, rangeMax }) => { - const result = applyCumulativeTransformToLineChartData({ - data, - rangeMin, - rangeMax, - }); - - expect(result).toMatchSnapshot(); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/calculateValueRangeFromLineChartSeries.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/calculateValueRangeFromLineChartSeries.test.ts new file mode 100644 index 0000000000..aff71507c9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/calculateValueRangeFromLineChartSeries.test.ts @@ -0,0 +1,146 @@ +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; +import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries'; +import { type LineChartSeries } from '~/generated/graphql'; + +describe('calculateValueRangeFromLineChartSeries', () => { + describe('with valid data', () => { + it('should calculate minimum and maximum from single series', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: 10 }, + { x: 'Feb', y: 50 }, + { x: 'Mar', y: 30 }, + ], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(50); + }); + + it('should calculate minimum and maximum from multiple series', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: 10 }, + { x: 'Feb', y: 20 }, + ], + }, + { + id: 'series2', + data: [ + { x: 'Jan', y: 5 }, + { x: 'Feb', y: 100 }, + ], + }, + ] as unknown as LineChartSeries[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(100); + }); + + it('should handle negative values', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: -50 }, + { x: 'Feb', y: 25 }, + { x: 'Mar', y: -10 }, + ], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(-50); + expect(result.maximum).toBe(25); + }); + + it('should handle all same values', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: 42 }, + { x: 'Feb', y: 42 }, + { x: 'Mar', y: 42 }, + ], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(42); + }); + }); + + describe('with null/undefined values', () => { + it('should treat null y values as 0', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: null }, + { x: 'Feb', y: 50 }, + ], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(50); + }); + + it('should treat undefined y values as 0', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [ + { x: 'Jan', y: undefined }, + { x: 'Feb', y: 30 }, + ], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(30); + }); + }); + + describe('empty data', () => { + it('should handle empty series array', () => { + const data: LineChartSeriesWithColor[] = []; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(0); + }); + + it('should handle series with empty data array', () => { + const data: LineChartSeriesWithColor[] = [ + { + id: 'series1', + data: [], + }, + ] as unknown as LineChartSeriesWithColor[]; + + const result = calculateValueRangeFromLineChartSeries(data); + + expect(result.minimum).toBe(0); + expect(result.maximum).toBe(0); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/computeLineChartGroupedLabels.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/computeLineChartGroupedLabels.test.ts new file mode 100644 index 0000000000..8718a08f63 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/computeLineChartGroupedLabels.test.ts @@ -0,0 +1,158 @@ +import { computeLineChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartGroupedLabels'; +import { type LineSeries, type Point } from '@nivo/line'; + +type MockPointOverrides = Omit>, 'data'> & { + data?: { x?: string | number; y?: string | number }; +}; + +describe('computeLineChartGroupedLabels', () => { + const createMockPoint = ( + overrides: MockPointOverrides, + ): Point => { + const { data: dataOverrides, ...restOverrides } = overrides; + return { + id: 'point1', + seriesId: 'series1', + x: 100, + y: 50, + data: { + x: 'Jan', + y: 100, + xFormatted: 'Jan', + yFormatted: '100', + ...dataOverrides, + }, + ...restOverrides, + } as unknown as Point; + }; + + describe('basic label computation', () => { + it('should return labels for each point', () => { + const points = [ + createMockPoint({ seriesId: 'series1', data: { x: 'Jan', y: 100 } }), + createMockPoint({ seriesId: 'series1', data: { x: 'Feb', y: 150 } }), + ]; + + const result = computeLineChartGroupedLabels(points); + + expect(result).toHaveLength(2); + }); + + it('should generate unique keys for each label', () => { + const points = [ + createMockPoint({ seriesId: 'revenue', data: { x: 'Q1', y: 100 } }), + createMockPoint({ seriesId: 'revenue', data: { x: 'Q2', y: 150 } }), + ]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].key).toBe('value-revenue-Q1'); + expect(result[1].key).toBe('value-revenue-Q2'); + }); + }); + + describe('label positioning', () => { + it('should use point x coordinate for label x', () => { + const points = [createMockPoint({ x: 250 })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].x).toBe(250); + }); + + it('should use point y coordinate for label y', () => { + const points = [createMockPoint({ y: 75 })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].y).toBe(75); + }); + }); + + describe('shouldRenderBelow logic', () => { + it('should set shouldRenderBelow to false for positive values', () => { + const points = [createMockPoint({ data: { x: 'Jan', y: 100 } })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].shouldRenderBelow).toBe(false); + }); + + it('should set shouldRenderBelow to true for negative values', () => { + const points = [createMockPoint({ data: { x: 'Jan', y: -50 } })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].shouldRenderBelow).toBe(true); + }); + + it('should set shouldRenderBelow to false for zero', () => { + const points = [createMockPoint({ data: { x: 'Jan', y: 0 } })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].shouldRenderBelow).toBe(false); + }); + }); + + describe('value extraction', () => { + it('should extract numeric value from point data', () => { + const points = [createMockPoint({ data: { x: 'Jan', y: 42.5 } })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].value).toBe(42.5); + }); + + it('should handle string y values by converting to number', () => { + const points = [ + createMockPoint({ data: { x: 'Jan', y: '123' as unknown as number } }), + ]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].value).toBe(123); + }); + + it('should handle large numbers', () => { + const points = [createMockPoint({ data: { x: 'Jan', y: 1000000 } })]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].value).toBe(1000000); + }); + }); + + describe('multiple series', () => { + it('should handle points from different series', () => { + const points = [ + createMockPoint({ seriesId: 'sales', data: { x: 'Jan', y: 100 } }), + createMockPoint({ seriesId: 'revenue', data: { x: 'Jan', y: 200 } }), + ]; + + const result = computeLineChartGroupedLabels(points); + + expect(result[0].key).toBe('value-sales-Jan'); + expect(result[1].key).toBe('value-revenue-Jan'); + }); + }); + + describe('edge cases', () => { + it('should return empty array for empty input', () => { + const result = computeLineChartGroupedLabels([]); + + expect(result).toEqual([]); + }); + + it('should handle single point', () => { + const points = [ + createMockPoint({ seriesId: 'series1', data: { x: 'Only', y: 50 } }), + ]; + + const result = computeLineChartGroupedLabels(points); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe(50); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/createAreaFillDef.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/createAreaFillDef.test.ts new file mode 100644 index 0000000000..61d66d5c23 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/createAreaFillDef.test.ts @@ -0,0 +1,95 @@ +import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; +import { createAreaFillDef } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/createAreaFillDef'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; + +describe('createAreaFillDef', () => { + const mockColorScheme: GraphColorScheme = { + name: 'blue', + solid: '#solid', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + it('should create a linear gradient definition with correct structure', () => { + const result = createAreaFillDef(mockColorScheme, 'area-gradient-1'); + + expect(result.id).toBe('area-gradient-1'); + expect(result.type).toBe('linearGradient'); + expect(result.x1).toBe('0%'); + expect(result.y1).toBe('0%'); + expect(result.x2).toBe('0%'); + expect(result.y2).toBe('100%'); + }); + + it('should create gradient colors with correct opacity values', () => { + const result = createAreaFillDef(mockColorScheme, 'test-gradient'); + + expect(result.colors).toHaveLength(2); + expect(result.colors[0]).toEqual({ + offset: 0, + color: mockColorScheme.solid, + opacity: LINE_CHART_CONSTANTS.AREA_FILL_START_OPACITY, + }); + expect(result.colors[1]).toEqual({ + offset: 100, + color: mockColorScheme.solid, + opacity: LINE_CHART_CONSTANTS.AREA_FILL_END_OPACITY, + }); + }); + + it('should use the solid color from color scheme', () => { + const customColorScheme: GraphColorScheme = { + name: 'custom', + solid: '#customSolid', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + const result = createAreaFillDef(customColorScheme, 'custom-gradient'); + + expect(result.colors[0].color).toBe('#customSolid'); + expect(result.colors[1].color).toBe('#customSolid'); + }); + + it('should generate unique IDs for different gradients', () => { + const result1 = createAreaFillDef(mockColorScheme, 'gradient-a'); + const result2 = createAreaFillDef(mockColorScheme, 'gradient-b'); + + expect(result1.id).toBe('gradient-a'); + expect(result2.id).toBe('gradient-b'); + expect(result1.id).not.toBe(result2.id); + }); + + it('should create vertical gradient (top to bottom)', () => { + const result = createAreaFillDef(mockColorScheme, 'vertical-gradient'); + + expect(result.x1).toBe('0%'); + expect(result.y1).toBe('0%'); + expect(result.x2).toBe('0%'); + expect(result.y2).toBe('100%'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartQueryLimit.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartQueryLimit.test.ts deleted file mode 100644 index 6fb02b69f7..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/getLineChartQueryLimit.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups'; -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { type LineChartConfiguration } from '~/generated-metadata/graphql'; -import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit'; - -describe('getLineChartQueryLimit', () => { - it('should return one-dimensional limit for line chart without secondary axis', () => { - const result = getLineChartQueryLimit({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: null, - isStacked: false, - } as LineChartConfiguration); - - expect(result).toBe( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); - - it('should return two-dimensional stacked limit for line chart with secondary axis and stacked mode', () => { - const result = getLineChartQueryLimit({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - isStacked: true, - } as LineChartConfiguration); - - expect(result).toBe( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS * - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); - - it('should return two-dimensional non-stacked limit for line chart with secondary axis and non-stacked mode', () => { - const result = getLineChartQueryLimit({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - isStacked: false, - } as LineChartConfiguration); - - expect(result).toBe( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS * - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, - ); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts deleted file mode 100644 index e1375dcaca..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { FirstDayOfTheWeek } from 'twenty-shared/types'; -import { - AggregateOperations, - FieldMetadataType, - WidgetConfigurationType, - type LineChartConfiguration, -} from '~/generated-metadata/graphql'; - -describe('transformOneDimensionalGroupByToLineChartData', () => { - const userTimezone = 'Europe/Paris'; - - const mockAggregateField: FieldMetadataItem = { - id: 'amount-field', - name: 'amount', - label: 'Amount', - type: FieldMetadataType.NUMBER, - } as FieldMetadataItem; - - const mockGroupByFieldX: FieldMetadataItem = { - id: 'stage-field', - name: 'stage', - label: 'Stage', - type: FieldMetadataType.TEXT, - } as FieldMetadataItem; - - const mockDateGroupByField: FieldMetadataItem = { - id: 'created-at-field', - name: 'createdAt', - label: 'Created At', - type: FieldMetadataType.DATE_TIME, - } as FieldMetadataItem; - - const mockObjectMetadataItem = { - id: 'opportunity-object', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [mockAggregateField, mockGroupByFieldX, mockDateGroupByField], - } as ObjectMetadataItem; - - const buildConfiguration = ( - overrides: Partial = {}, - ): LineChartConfiguration => - ({ - __typename: 'LineChartConfiguration', - configurationType: WidgetConfigurationType.LINE_CHART, - aggregateFieldMetadataId: 'amount-field', - aggregateOperation: AggregateOperations.SUM, - primaryAxisGroupByFieldMetadataId: 'stage-field', - color: 'blue', - ...overrides, - }) as LineChartConfiguration; - - describe('Categorical X-axis', () => { - it('should transform simple categorical groupBy results', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['Qualification'], - sumAmount: 150000, - }, - { - groupByDimensionValues: ['Proposal'], - sumAmount: 280000, - }, - { - groupByDimensionValues: ['Closed Won'], - sumAmount: 450000, - }, - ]; - - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toHaveLength(1); - expect(result.series[0]).toMatchObject({ - id: 'amount', - label: 'Amount', - color: 'blue', - }); - expect(result.series[0].data).toEqual([ - { x: 'Qualification', y: 150000 }, - { x: 'Proposal', y: 280000 }, - { x: 'Closed Won', y: 450000 }, - ]); - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should treat null aggregate values as zero', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['Stage A'], - sumAmount: 100, - }, - { - groupByDimensionValues: ['Stage B'], - sumAmount: null, - }, - { - groupByDimensionValues: ['Stage C'], - sumAmount: 200, - }, - ]; - - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series[0].data).toEqual([ - { x: 'Stage A', y: 100 }, - { x: 'Stage B', y: 0 }, - { x: 'Stage C', y: 200 }, - ]); - expect(result.hasTooManyGroups).toBe(false); - }); - }); - - describe('Time-series X-axis', () => { - it('should transform date-based groupBy results', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01'], - sumAmount: 50000, - }, - { - groupByDimensionValues: ['2024-02-01'], - sumAmount: 75000, - }, - { - groupByDimensionValues: ['2024-03-01'], - sumAmount: 60000, - }, - ]; - - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockDateGroupByField, - aggregateField: mockAggregateField, - configuration: buildConfiguration({ - primaryAxisGroupByFieldMetadataId: 'created-at-field', - primaryAxisDateGranularity: 'MONTH' as any, - }), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toHaveLength(1); - expect(result.series[0].data).toHaveLength(3); - expect(result.series[0].data[0]).toHaveProperty('x'); - expect(result.series[0].data[0]).toHaveProperty('y', 50000); - }); - }); - - describe('Edge cases', () => { - it('should handle empty results', () => { - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults: [], - groupByFieldX: mockGroupByFieldX, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toHaveLength(1); - expect(result.series[0].data).toEqual([]); - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should use default color when not specified', () => { - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults: [ - { - groupByDimensionValues: ['Test'], - sumAmount: 100, - }, - ], - groupByFieldX: mockGroupByFieldX, - aggregateField: mockAggregateField, - configuration: buildConfiguration({ color: undefined }), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series[0].color).toBeDefined(); - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should handle COUNT operation', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['Stage A'], - _count: 5, - }, - { - groupByDimensionValues: ['Stage B'], - _count: 10, - }, - ]; - - const result = transformOneDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - aggregateField: mockAggregateField, - configuration: buildConfiguration({ - aggregateOperation: AggregateOperations.COUNT, - }), - aggregateOperation: '_count', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series[0].data).toEqual([ - { x: 'Stage A', y: 5 }, - { x: 'Stage B', y: 10 }, - ]); - expect(result.hasTooManyGroups).toBe(false); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts deleted file mode 100644 index b442b63dbc..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { FirstDayOfTheWeek } from 'twenty-shared/types'; -import { - AggregateOperations, - FieldMetadataType, - WidgetConfigurationType, - type LineChartConfiguration, -} from '~/generated-metadata/graphql'; - -describe('transformTwoDimensionalGroupByToLineChartData', () => { - const userTimezone = 'Europe/Paris'; - - const mockAggregateField: FieldMetadataItem = { - id: 'amount-field', - name: 'amount', - label: 'Amount', - type: FieldMetadataType.NUMBER, - } as FieldMetadataItem; - - const mockGroupByFieldX: FieldMetadataItem = { - id: 'created-at-field', - name: 'createdAt', - label: 'Created At', - type: FieldMetadataType.DATE_TIME, - } as FieldMetadataItem; - - const mockGroupByFieldY: FieldMetadataItem = { - id: 'stage-field', - name: 'stage', - label: 'Stage', - type: FieldMetadataType.TEXT, - } as FieldMetadataItem; - - const mockObjectMetadataItem = { - id: 'opportunity-object', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [mockAggregateField, mockGroupByFieldX, mockGroupByFieldY], - } as ObjectMetadataItem; - - const buildConfiguration = ( - overrides: Partial = {}, - ): LineChartConfiguration => - ({ - __typename: 'LineChartConfiguration', - configurationType: WidgetConfigurationType.LINE_CHART, - aggregateFieldMetadataId: 'amount-field', - aggregateOperation: AggregateOperations.SUM, - primaryAxisGroupByFieldMetadataId: 'created-at-field', - secondaryAxisGroupByFieldMetadataId: 'stage-field', - color: 'blue', - ...overrides, - }) as LineChartConfiguration; - - describe('Multi-series transformation', () => { - it('should create multiple series from 2D groupBy results', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01', 'Qualification'], - sumAmount: 50000, - }, - { - groupByDimensionValues: ['2024-01-01', 'Proposal'], - sumAmount: 75000, - }, - { - groupByDimensionValues: ['2024-02-01', 'Qualification'], - sumAmount: 60000, - }, - { - groupByDimensionValues: ['2024-02-01', 'Proposal'], - sumAmount: 90000, - }, - { - groupByDimensionValues: ['2024-03-01', 'Qualification'], - sumAmount: 55000, - }, - { - groupByDimensionValues: ['2024-03-01', 'Proposal'], - sumAmount: 80000, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toHaveLength(2); - - expect(result.series[0]).toMatchObject({ - id: expect.any(String), - label: expect.any(String), - color: 'blue', - }); - - expect(result.series[0].data).toHaveLength(3); - expect(result.series[1].data).toHaveLength(3); - - expect(result.series[0].data[0]).toHaveProperty('x'); - expect(result.series[0].data[0]).toHaveProperty('y'); - - result.series.forEach((series) => { - series.data.forEach((point) => { - expect(point).toHaveProperty('x'); - expect(point).toHaveProperty('y'); - }); - }); - - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should preserve backend ordering of data points', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01', 'Stage A'], - sumAmount: 100, - }, - { - groupByDimensionValues: ['2024-02-01', 'Stage A'], - sumAmount: 200, - }, - { - groupByDimensionValues: ['2024-03-01', 'Stage A'], - sumAmount: 300, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - const series = result.series[0]; - expect(series.data[0].y).toBe(100); - expect(series.data[1].y).toBe(200); - expect(series.data[2].y).toBe(300); - - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should normalize sparse data (all series share same X values, with 0 for missing)', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01', 'Stage A'], - sumAmount: 100, - }, - { - groupByDimensionValues: ['2024-02-01', 'Stage A'], - sumAmount: 200, - }, - { - groupByDimensionValues: ['2024-01-01', 'Stage B'], - sumAmount: 150, - }, - { - groupByDimensionValues: ['2024-03-01', 'Stage B'], - sumAmount: 250, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - const stageA = result.series.find((s) => s.id === 'Stage A'); - expect(stageA?.data).toHaveLength(3); - expect(stageA?.data[0].y).toBe(100); - expect(stageA?.data[1].y).toBe(200); - expect(stageA?.data[2].y).toBe(0); - - const stageB = result.series.find((s) => s.id === 'Stage B'); - expect(stageB?.data).toHaveLength(3); - expect(stageB?.data[0].y).toBe(150); - expect(stageB?.data[1].y).toBe(0); - expect(stageB?.data[2].y).toBe(250); - - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should convert null aggregate values to zero', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01', 'Stage A'], - sumAmount: 100, - }, - { - groupByDimensionValues: ['2024-02-01', 'Stage A'], - sumAmount: null, - }, - { - groupByDimensionValues: ['2024-03-01', 'Stage A'], - sumAmount: 200, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series[0].data).toHaveLength(3); - expect(result.series[0].data.map((d) => d.y)).toEqual([100, 0, 200]); - - expect(result.hasTooManyGroups).toBe(false); - }); - }); - - describe('Edge cases', () => { - it('should handle empty results', () => { - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults: [], - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toEqual([]); - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should skip results with missing dimension values', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01'], - sumAmount: 100, - }, - { - groupByDimensionValues: ['2024-02-01', 'Stage A'], - sumAmount: 200, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration(), - aggregateOperation: 'sumAmount', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series).toHaveLength(1); - expect(result.series[0].data).toHaveLength(1); - - expect(result.hasTooManyGroups).toBe(false); - }); - - it('should handle COUNT operation', () => { - const rawResults: GroupByRawResult[] = [ - { - groupByDimensionValues: ['2024-01-01', 'Stage A'], - _count: 5, - }, - { - groupByDimensionValues: ['2024-02-01', 'Stage A'], - _count: 10, - }, - ]; - - const result = transformTwoDimensionalGroupByToLineChartData({ - rawResults, - groupByFieldX: mockGroupByFieldX, - groupByFieldY: mockGroupByFieldY, - aggregateField: mockAggregateField, - configuration: buildConfiguration({ - aggregateOperation: AggregateOperations.COUNT, - }), - aggregateOperation: '_count', - objectMetadataItem: mockObjectMetadataItem, - primaryAxisSubFieldName: null, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]); - - expect(result.hasTooManyGroups).toBe(false); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData.ts deleted file mode 100644 index bcabd7cbe7..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; -import { isDefined } from 'twenty-shared/utils'; - -type ApplyCumulativeTransformToLineChartDataOptions = { - data: LineChartDataPoint[]; - rangeMin?: number; - rangeMax?: number; -}; - -export const applyCumulativeTransformToLineChartData = ({ - data, - rangeMin, - rangeMax, -}: ApplyCumulativeTransformToLineChartDataOptions): LineChartDataPoint[] => { - const { result } = data.reduce<{ - result: LineChartDataPoint[]; - runningTotal: number; - }>( - (accumulator, point) => { - if (point.y !== null) { - accumulator.runningTotal += point.y; - } - - const cumulativeValue = accumulator.runningTotal; - - const isOutOfRange = - (isDefined(rangeMin) && cumulativeValue < rangeMin) || - (isDefined(rangeMax) && cumulativeValue > rangeMax); - - if (!isOutOfRange) { - accumulator.result.push({ ...point, y: cumulativeValue }); - } - - return accumulator; - }, - { result: [], runningTotal: 0 }, - ); - - return result; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts deleted file mode 100644 index 5331140598..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; -import { type ProcessedTwoDimensionalDataPoint } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults'; - -type BuildTwoDimensionalLineChartSeriesParams = { - processedDataPoints: ProcessedTwoDimensionalDataPoint[]; - color?: GraphColor | null; -}; - -type BuildTwoDimensionalLineChartSeriesResult = { - unsortedSeries: LineChartSeries[]; -}; - -export const buildTwoDimensionalLineChartSeries = ({ - processedDataPoints, - color, -}: BuildTwoDimensionalLineChartSeriesParams): BuildTwoDimensionalLineChartSeriesResult => { - const seriesMap = new Map>(); - const allXValues: string[] = []; - const xValueSet = new Set(); - - for (const { xValue, yValue, aggregateValue } of processedDataPoints) { - const isNewX = !xValueSet.has(xValue); - - if (isNewX) { - xValueSet.add(xValue); - allXValues.push(xValue); - } - - if (!seriesMap.has(yValue)) { - seriesMap.set(yValue, new Map()); - } - - seriesMap.get(yValue)!.set(xValue, aggregateValue); - } - - const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map( - ([seriesKey, xToYMap]) => { - const data: LineChartDataPoint[] = allXValues.map((xValue) => ({ - x: xValue, - y: xToYMap.get(xValue) ?? 0, - })); - - return { - id: seriesKey, - label: seriesKey, - color: color ?? undefined, - data, - }; - }, - ); - - return { - unsortedSeries, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts index ab16f1a927..f070e913a6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts @@ -1,15 +1,15 @@ -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; +import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; export const calculateValueRangeFromLineChartSeries = ( - data: LineChartSeries[], + data: LineChartSeriesWithColor[], ): ChartValueRange => { const values: number[] = []; for (const series of data) { for (const point of series.data) { - const value = Number(point.y ?? 0); + const value = Number(point.y); values.push(value); } } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineAreaPath.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineAreaPath.ts index 7bbb6e1ac6..87af75da4d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineAreaPath.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineAreaPath.ts @@ -19,7 +19,7 @@ export const computeLineAreaPath = ({ type PositionData = (typeof currentSeries.data)[number]; const areaGenerator = area() - .defined((d) => d.position.x !== null && d.position.y !== null) + .defined((d) => isDefined(d.position.x) && isDefined(d.position.y)) .x((d) => d.position.x ?? 0) .y1((d) => d.position.y ?? 0) .y0((_, index) => { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts index c1a489d412..43a7dc6964 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts @@ -1,5 +1,7 @@ +import { isNumber, isString } from '@sniptt/guards'; + import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues'; export const computeLineChartCategoryTickValues = ({ @@ -9,7 +11,7 @@ export const computeLineChartCategoryTickValues = ({ marginRight, }: { width: number; - data: LineChartSeries[]; + data: LineChartSeriesWithColor[]; marginLeft: number; marginRight: number; }): (string | number)[] => { @@ -19,9 +21,7 @@ export const computeLineChartCategoryTickValues = ({ const values = data[0].data.map((point) => { const value = point.x; - return typeof value === 'number' || typeof value === 'string' - ? value - : String(value); + return isNumber(value) || isString(value) ? value : String(value); }); const availableWidth = width - (marginLeft + marginRight); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts index 0eab6188c8..35d4a40bb4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts @@ -1,6 +1,6 @@ import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor'; import { computeLineChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues'; import { getTickRotationConfig } from '@/page-layout/widgets/graph/utils/getTickRotationConfig'; import { isNonEmptyArray } from '@sniptt/guards'; @@ -23,7 +23,7 @@ export type LineChartAxisBottomResult = { export const getLineChartAxisBottomConfig = ( xAxisLabel?: string, width?: number, - data?: LineChartSeries[], + data?: LineChartSeriesWithColor[], marginLeft?: number, ): LineChartAxisBottomResult => { const effectiveMarginLeft = diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit.ts deleted file mode 100644 index 2fa0cad6e2..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups'; -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { isChartConfigurationTwoDimensional } from '@/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional'; -import { type LineChartConfiguration } from '~/generated/graphql'; - -export const getLineChartQueryLimit = ( - configuration: LineChartConfiguration, -): number => { - const isTwoDimensional = isChartConfigurationTwoDimensional(configuration); - - if (!isTwoDimensional) { - return ( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS - ); - } - - if (configuration.isStacked === true) { - return ( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS * - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS - ); - } - - return ( - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS * - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES + - EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts deleted file mode 100644 index db9e150313..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; - -type LimitTwoDimensionalLineChartDataParams = { - sortedSeries: LineChartSeries[]; - isStacked: boolean; -}; - -type LimitTwoDimensionalLineChartDataResult = { - limitedSeries: LineChartSeries[]; - hasTooManyGroups: boolean; -}; - -// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) -export const limitTwoDimensionalLineChartData = ({ - sortedSeries, - isStacked, -}: LimitTwoDimensionalLineChartDataParams): LimitTwoDimensionalLineChartDataResult => { - if (sortedSeries.length === 0) { - return { - limitedSeries: sortedSeries, - hasTooManyGroups: false, - }; - } - - const maxSeries = isStacked - ? LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES - : LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES; - - const hasTooManySeries = sortedSeries.length > maxSeries; - - const dataPointCount = sortedSeries[0].data.length; - const hasTooManyDataPoints = - dataPointCount > LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS; - - const hasTooManyGroups = hasTooManySeries || hasTooManyDataPoints; - - if (!hasTooManyGroups) { - return { - limitedSeries: sortedSeries, - hasTooManyGroups: false, - }; - } - - const seriesLimited = sortedSeries.slice(0, maxSeries); - - const limitedSeries = seriesLimited.map((series) => ({ - ...series, - data: series.data.slice( - 0, - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS, - ), - })); - - return { - limitedSeries, - hasTooManyGroups: true, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts deleted file mode 100644 index 8a589c3e17..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { GraphOrderBy } from '~/generated/graphql'; - -type SortLineChartDataBySecondaryDimensionSumParams = { - series: LineChartSeries[]; - orderBy: GraphOrderBy; -}; - -export const sortLineChartDataBySecondaryDimensionSum = ({ - series, - orderBy, -}: SortLineChartDataBySecondaryDimensionSumParams): LineChartSeries[] => { - if (series.length === 0) { - return series; - } - - const allXValues = series[0].data.map((point) => point.x); - - const seriesLookups = series.map( - (seriesItem) => new Map(seriesItem.data.map((point) => [point.x, point.y])), - ); - - const xValueSums = new Map< - LineChartDataPoint['x'], - LineChartDataPoint['y'] - >(); - - for (const xValue of allXValues) { - const sum = seriesLookups.reduce((accumulator, lookup) => { - return accumulator + (lookup.get(xValue) ?? 0); - }, 0); - xValueSums.set(xValue, sum); - } - - const sortedXValues = allXValues.toSorted((a, b) => { - const sumA = xValueSums.get(a) ?? 0; - const sumB = xValueSums.get(b) ?? 0; - - return orderBy === GraphOrderBy.VALUE_ASC ? sumA - sumB : sumB - sumA; - }); - - const xValueToIndex = new Map( - sortedXValues.map((xValue, index) => [xValue, index]), - ); - - return series.map((seriesItem) => ({ - ...seriesItem, - data: seriesItem.data.toSorted((a, b) => { - const indexA = xValueToIndex.get(a.x) ?? 0; - const indexB = xValueToIndex.get(b.x) ?? 0; - - return indexA - indexB; - }), - })); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts deleted file mode 100644 index 986a1a51fd..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { sortLineChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData'; -import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { type FieldMetadataType } from '~/generated-metadata/graphql'; -import { type LineChartConfiguration, GraphOrderBy } from '~/generated/graphql'; - -type SortTwoDimensionalLineChartDataConfiguration = { - series: LineChartSeries[]; - configuration: LineChartConfiguration; - primaryAxisFormattedToRawLookup: Map; - primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null; - secondaryAxisFormattedToRawLookup?: Map; - secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null; - secondaryAxisFieldType?: FieldMetadataType; - secondaryAxisSubFieldName?: CompositeFieldSubFieldName; -}; - -type SortTwoDimensionalLineChartDataResult = { - sortedSeries: LineChartSeries[]; - colorMode: GraphColorMode; -}; - -export const sortTwoDimensionalLineChartData = ({ - series, - configuration: { - primaryAxisOrderBy, - primaryAxisManualSortOrder, - secondaryAxisOrderBy, - secondaryAxisManualSortOrder, - color, - }, - primaryAxisFormattedToRawLookup, - primaryAxisSelectFieldOptions, - secondaryAxisFormattedToRawLookup, - secondaryAxisSelectFieldOptions, - secondaryAxisFieldType, - secondaryAxisSubFieldName, -}: SortTwoDimensionalLineChartDataConfiguration): SortTwoDimensionalLineChartDataResult => { - let sortedSeries = series; - - if (isDefined(primaryAxisOrderBy)) { - if ( - primaryAxisOrderBy === GraphOrderBy.VALUE_ASC || - primaryAxisOrderBy === GraphOrderBy.VALUE_DESC - ) { - sortedSeries = sortLineChartDataBySecondaryDimensionSum({ - series, - orderBy: primaryAxisOrderBy, - }); - } else { - sortedSeries = series.map((seriesItem) => { - const sortedDataPoints = - sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({ - data: seriesItem.data, - orderBy: primaryAxisOrderBy, - manualSortOrder: primaryAxisManualSortOrder, - formattedToRawLookup: primaryAxisFormattedToRawLookup, - getFormattedValue: (dataPoint: LineChartDataPoint) => - String(dataPoint.x), - selectFieldOptions: primaryAxisSelectFieldOptions, - }); - - return { - ...seriesItem, - data: sortedDataPoints, - }; - }); - } - } - - sortedSeries = sortSecondaryAxisData({ - items: sortedSeries, - orderBy: secondaryAxisOrderBy, - manualSortOrder: secondaryAxisManualSortOrder, - formattedToRawLookup: secondaryAxisFormattedToRawLookup, - selectFieldOptions: secondaryAxisSelectFieldOptions, - getFormattedValue: (item) => item.id, - fieldType: secondaryAxisFieldType, - subFieldName: secondaryAxisSubFieldName, - }); - - sortedSeries = sortedSeries.map((seriesItem) => { - const rawValue = secondaryAxisFormattedToRawLookup?.get(seriesItem.id); - - return { - ...seriesItem, - color: determineChartItemColor({ - configurationColor: parseGraphColor(color), - selectOptions: secondaryAxisSelectFieldOptions, - rawValue: isDefined(rawValue) ? String(rawValue) : seriesItem.id, - }), - }; - }); - - const colorMode = determineGraphColorMode({ - configurationColor: color, - selectFieldOptions: secondaryAxisSelectFieldOptions, - }); - - return { - sortedSeries, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts deleted file mode 100644 index ad070a4619..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData'; -import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData'; -import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; -import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { - type FirstDayOfTheWeek, - isDefined, - isFieldMetadataDateKind, -} from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { - AxisNameDisplay, - type LineChartConfiguration, -} from '~/generated/graphql'; - -type TransformGroupByDataToLineChartDataParams = { - groupByData: Record | null | undefined; - objectMetadataItem: ObjectMetadataItem; - objectMetadataItems: ObjectMetadataItem[]; - configuration: LineChartConfiguration; - aggregateOperation: string; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformGroupByDataToLineChartDataResult = { - series: LineChartSeries[]; - xAxisLabel?: string; - yAxisLabel?: string; - showDataLabels: boolean; - showLegend: boolean; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -const EMPTY_LINE_CHART_RESULT: Omit< - TransformGroupByDataToLineChartDataResult, - 'xAxisLabel' | 'yAxisLabel' -> = { - series: [], - showDataLabels: false, - showLegend: true, - hasTooManyGroups: false, - formattedToRawLookup: new Map(), - colorMode: 'automaticPalette', -}; - -export const transformGroupByDataToLineChartData = ({ - groupByData, - objectMetadataItem, - objectMetadataItems, - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek, -}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => { - const groupByFieldX = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.primaryAxisGroupByFieldMetadataId, - ); - - const groupByFieldY = isDefined( - configuration.secondaryAxisGroupByFieldMetadataId, - ) - ? objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.secondaryAxisGroupByFieldMetadataId, - ) - : undefined; - - const aggregateField = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.aggregateFieldMetadataId, - ); - - const queryResultGqlFieldName = - getGroupByQueryResultGqlFieldName(objectMetadataItem); - const rawResults = groupByData?.[queryResultGqlFieldName]; - - const showXAxis = - configuration.axisNameDisplay === AxisNameDisplay.X || - configuration.axisNameDisplay === AxisNameDisplay.BOTH; - - const showYAxis = - configuration.axisNameDisplay === AxisNameDisplay.Y || - configuration.axisNameDisplay === AxisNameDisplay.BOTH; - - const xAxisLabel = - showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined; - - const yAxisLabel = - showYAxis && isDefined(aggregateField) - ? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}` - : undefined; - - if (!isDefined(groupByData)) { - return { - ...EMPTY_LINE_CHART_RESULT, - xAxisLabel, - yAxisLabel, - }; - } - - if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) { - return { - ...EMPTY_LINE_CHART_RESULT, - xAxisLabel, - yAxisLabel, - }; - } - - const primaryAxisSubFieldName = - configuration.primaryAxisGroupBySubFieldName ?? undefined; - const secondaryAxisSubFieldName = - configuration.secondaryAxisGroupBySubFieldName ?? undefined; - - if (!isDefined(rawResults) || !Array.isArray(rawResults)) { - return { - ...EMPTY_LINE_CHART_RESULT, - xAxisLabel, - yAxisLabel, - }; - } - - const filteredResults = filterGroupByResults({ - rawResults, - filterOptions: { - rangeMin: configuration.isCumulative - ? undefined - : (configuration.rangeMin ?? undefined), - rangeMax: configuration.isCumulative - ? undefined - : (configuration.rangeMax ?? undefined), - omitNullValues: configuration.omitNullValues ?? false, - }, - aggregateField, - aggregateOperation: - configuration.aggregateOperation as unknown as ExtendedAggregateOperations, - aggregateOperationFromRawResult: aggregateOperation, - objectMetadataItem, - }); - - const showDataLabels = configuration.displayDataLabel ?? false; - const showLegend = configuration.displayLegend ?? true; - - const omitNullValues = configuration.omitNullValues ?? false; - const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT; - const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues; - - const resultsWithSelectGaps = shouldApplySelectGapFill - ? fillSelectGapsInChartData({ - data: filteredResults, - selectOptions: groupByFieldX.options, - aggregateKeys: [aggregateField.name], - hasSecondDimension: isDefined(groupByFieldY), - }) - : filteredResults; - - const isDateField = isFieldMetadataDateKind(groupByFieldX.type); - const isNestedDateField = isRelationNestedFieldDateKind({ - relationField: groupByFieldX, - relationNestedFieldName: primaryAxisSubFieldName, - objectMetadataItems, - }); - - const primaryAxisDateGranularity = - isDateField || isNestedDateField - ? configuration.primaryAxisDateGranularity - : undefined; - - const isSecondaryDateField = isDefined(groupByFieldY) - ? isFieldMetadataDateKind(groupByFieldY.type) - : false; - - const isSecondaryNestedDateField = - isDefined(groupByFieldY) && - isRelationNestedFieldDateKind({ - relationField: groupByFieldY, - relationNestedFieldName: secondaryAxisSubFieldName, - objectMetadataItems, - }); - - const secondaryAxisDateGranularity = - isSecondaryDateField || isSecondaryNestedDateField - ? configuration.secondaryAxisGroupByDateGranularity - : undefined; - - const sanitizedConfiguration: LineChartConfiguration = { - ...configuration, - primaryAxisDateGranularity: primaryAxisDateGranularity ?? undefined, - secondaryAxisGroupByDateGranularity: - secondaryAxisDateGranularity ?? undefined, - }; - - const baseResult = isDefined(groupByFieldY) - ? transformTwoDimensionalGroupByToLineChartData({ - rawResults: resultsWithSelectGaps, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration: sanitizedConfiguration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }) - : transformOneDimensionalGroupByToLineChartData({ - rawResults: resultsWithSelectGaps, - groupByFieldX, - aggregateField, - configuration: sanitizedConfiguration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - return { - ...baseResult, - xAxisLabel, - yAxisLabel, - showDataLabels, - showLegend, - formattedToRawLookup: baseResult.formattedToRawLookup, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts deleted file mode 100644 index 65d26b8f9a..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant'; -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults'; -import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded'; -import { - isFieldMetadataSelectKind, - type FirstDayOfTheWeek, -} from 'twenty-shared/utils'; -import { type LineChartConfiguration } from '~/generated/graphql'; - -type TransformOneDimensionalGroupByToLineChartDataParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: LineChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformOneDimensionalGroupByToLineChartDataResult = { - series: LineChartSeries[]; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -export const transformOneDimensionalGroupByToLineChartData = ({ - rawResults, - groupByFieldX, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => { - const { processedDataPoints, formattedToRawLookup } = - processOneDimensionalGroupByResults({ - rawResults, - groupByFieldX, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - const unsortedData: LineChartDataPoint[] = processedDataPoints.map( - ({ xValue, aggregateValue }) => ({ - x: xValue, - y: aggregateValue, - }), - ); - - const sortedData = sortChartDataIfNeeded({ - data: unsortedData, - orderBy: configuration.primaryAxisOrderBy, - manualSortOrder: configuration.primaryAxisManualSortOrder, - formattedToRawLookup, - getFieldValue: (point) => String(point.x), - getNumericValue: (point) => point.y ?? 0, - selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type) - ? groupByFieldX.options - : undefined, - }); - - const limitedSortedData = sortedData.slice( - 0, - LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS, - ); - - const transformedData = configuration.isCumulative - ? applyCumulativeTransformToLineChartData({ - data: limitedSortedData, - rangeMin: configuration.rangeMin ?? undefined, - rangeMax: configuration.rangeMax ?? undefined, - }) - : limitedSortedData; - - const series: LineChartSeries[] = [ - { - id: aggregateField.name, - label: aggregateField.label, - color: parseGraphColor(configuration.color) ?? GRAPH_DEFAULT_COLOR, - data: transformedData, - }, - ]; - - const colorMode = determineGraphColorMode({ - configurationColor: configuration.color, - selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type) - ? groupByFieldX.options - : undefined, - }); - - return { - series, - hasTooManyGroups: - rawResults.length > LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS, - formattedToRawLookup, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts deleted file mode 100644 index de687f0420..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants'; -import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; -import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData'; -import { buildTwoDimensionalLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries'; -import { limitTwoDimensionalLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData'; -import { sortTwoDimensionalLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; -import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; -import { type LineChartConfiguration } from '~/generated/graphql'; - -type TransformTwoDimensionalGroupByToLineChartDataParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - groupByFieldY: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: LineChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformTwoDimensionalGroupByToLineChartDataResult = { - series: LineChartSeries[]; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -export const transformTwoDimensionalGroupByToLineChartData = ({ - rawResults, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => { - const { processedDataPoints, formattedToRawLookup, yFormattedToRawLookup } = - processTwoDimensionalGroupByResults({ - rawResults, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - const { unsortedSeries } = buildTwoDimensionalLineChartSeries({ - processedDataPoints, - color: parseGraphColor(configuration.color), - }); - - const { sortedSeries, colorMode } = sortTwoDimensionalLineChartData({ - series: unsortedSeries, - configuration, - primaryAxisFormattedToRawLookup: formattedToRawLookup, - primaryAxisSelectFieldOptions: groupByFieldX.options, - secondaryAxisFormattedToRawLookup: yFormattedToRawLookup, - secondaryAxisSelectFieldOptions: groupByFieldY.options, - secondaryAxisFieldType: groupByFieldY.type, - secondaryAxisSubFieldName: - (configuration.secondaryAxisGroupBySubFieldName ?? undefined) as - | CompositeFieldSubFieldName - | undefined, - }); - - const { limitedSeries, hasTooManyGroups } = limitTwoDimensionalLineChartData({ - sortedSeries, - isStacked: - configuration.isStacked ?? LINE_CHART_CONSTANTS.IS_STACKED_DEFAULT, - }); - - const finalSeries = configuration.isCumulative - ? limitedSeries.map((seriesItem) => ({ - ...seriesItem, - data: applyCumulativeTransformToLineChartData({ - data: seriesItem.data, - rangeMin: configuration.rangeMin ?? undefined, - rangeMax: configuration.rangeMax ?? undefined, - }), - })) - : limitedSeries; - - return { - series: finalSeries, - hasTooManyGroups, - formattedToRawLookup, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx index 3f7fa24b64..369a790f7b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/CustomArcsLayer.tsx @@ -1,5 +1,5 @@ import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useArcsTransition } from '@nivo/arcs'; @@ -8,12 +8,12 @@ import { animated } from '@react-spring/web'; import { isDefined } from 'twenty-shared/utils'; type CustomArcsLayerProps = Pick< - PieCustomLayerProps, + PieCustomLayerProps, 'dataWithArc' | 'arcGenerator' | 'centerX' | 'centerY' > & { - onMouseMove?: MouseEventHandler; - onMouseLeave?: MouseEventHandler; - onClick?: MouseEventHandler; + onMouseMove?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; + onClick?: MouseEventHandler; }; export const CustomArcsLayer = ({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphPieChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphPieChartTooltip.tsx index cb8b6af110..47a04e48cd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphPieChartTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphPieChartTooltip.tsx @@ -1,7 +1,7 @@ import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; import { PIE_CHART_TOOLTIP_OFFSET_PX } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartTooltipOffsetPx'; import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; import { getPieChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData'; import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset'; @@ -15,7 +15,7 @@ type GraphPieChartTooltipProps = { enrichedData: PieChartEnrichedData[]; formatOptions: GraphValueFormatOptions; displayType?: string; - onSliceClick?: (datum: PieChartDataItem) => void; + onSliceClick?: (datum: PieChartDataItemWithColor) => void; }; export const GraphPieChartTooltip = ({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx index c8c348eb01..fa3fe61f1f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx @@ -7,7 +7,7 @@ import { PIE_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWid import { PIE_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMargins'; import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData'; import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { getPieChartFormattedValue } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; @@ -27,21 +27,24 @@ import { type MouseEvent as ReactMouseEvent, } from 'react'; import { isDefined } from 'twenty-shared/utils'; -import { type PieChartConfiguration } from '~/generated/graphql'; +import { + type PieChartConfiguration, + type PieChartDataItem, +} from '~/generated/graphql'; type GraphWidgetPieChartProps = { - data: PieChartDataItem[]; + data: PieChartDataItemWithColor[]; showLegend?: boolean; id: string; objectMetadataItemId: string; configuration: PieChartConfiguration; colorMode: GraphColorMode; - onSliceClick?: (datum: PieChartDataItem) => void; + onSliceClick?: (datum: PieChartDataItemWithColor) => void; showDataLabels?: boolean; showCenterMetric?: boolean; } & GraphValueFormatOptions; -const emptyStateData: PieChartDataItem[] = [{ id: 'empty', value: 1 }]; +const emptyStateData: PieChartDataItemWithColor[] = [{ id: 'empty', value: 1 }]; const StyledContainer = styled.div` align-items: center; @@ -107,7 +110,7 @@ export const GraphWidgetPieChart = ({ const handleSliceMove = useCallback( ( - datum: ComputedDatum, + datum: ComputedDatum, event: ReactMouseEvent, ) => { if (!isDefined(containerRef.current)) return; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx index a591b621cd..6ea5ecaedb 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx @@ -2,7 +2,7 @@ import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPag import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { useGraphPieChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { assertPieChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertPieChartWidget'; import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { isFilteredViewRedirectionSupported } from '@/page-layout/widgets/graph/utils/isFilteredViewRedirectionSupported'; @@ -66,7 +66,7 @@ export const GraphWidgetPieChartRenderer = () => { const canRedirectToFilteredView = isFilteredViewRedirectionSupported(groupByField); - const handleSliceClick = (datum: PieChartDataItem) => { + const handleSliceClick = (datum: PieChartDataItemWithColor) => { const rawValue = formattedToRawLookup.get(datum.id) ?? null; const drilldownQueryParams = buildChartDrilldownQueryParams({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts index 6462925cab..44b4870e9b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/__tests__/usePieChartData.test.ts @@ -1,7 +1,8 @@ -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { renderHook } from '@testing-library/react'; -import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData'; +import { type PieChartDataItem } from '~/generated/graphql'; const mockUseRecoilComponentValue = jest.fn(); jest.mock( @@ -56,7 +57,7 @@ describe('usePieChartData', () => { }, }; - const mockData: PieChartDataItem[] = [ + const mockData: PieChartDataItemWithColor[] = [ { id: 'item1', value: 30 }, { id: 'item2', value: 50 }, { id: 'item3', value: 20 }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts index 03490479ad..8b55d3c9b6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts @@ -1,15 +1,19 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups'; -import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; -import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData'; -import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { PIE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/pieChartData'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; +import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; +import { extractPieChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractPieChartDataConfiguration'; +import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; +import { useQuery } from '@apollo/client'; +import { isString } from '@sniptt/guards'; import { useMemo } from 'react'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { type PieChartConfiguration } from '~/generated/graphql'; type UseGraphPieChartWidgetDataProps = { @@ -18,7 +22,7 @@ type UseGraphPieChartWidgetDataProps = { }; type UseGraphPieChartWidgetDataResult = { - data: PieChartDataItem[]; + data: PieChartDataItemWithColor[]; showLegend: boolean; loading: boolean; error?: Error; @@ -38,46 +42,76 @@ export const useGraphPieChartWidgetData = ({ objectId: objectMetadataItemId, }); + const apolloCoreClient = useApolloCoreClient(); + + const dataConfiguration = useMemo( + () => extractPieChartDataConfiguration(configuration), + [configuration], + ); + const { - data: groupByData, + data: queryData, loading, error, - aggregateOperation, - } = useGraphWidgetGroupByQuery({ - objectMetadataItemId, - configuration, - limit: - PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, + } = useQuery(PIE_CHART_DATA, { + client: apolloCoreClient, + variables: { + input: { + objectMetadataId: objectMetadataItemId, + configuration: dataConfiguration, + }, + }, }); - const { userTimezone } = useUserTimezone(); - const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const formattedToRawLookup = queryData?.pieChartData?.formattedToRawLookup + ? new Map(Object.entries(queryData.pieChartData.formattedToRawLookup)) + : new Map(); - const transformedData = useMemo( - () => - transformGroupByDataToPieChartData({ - groupByData, - objectMetadataItem, - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek: userFirstDayOfTheWeek, - }), - [ - groupByData, - objectMetadataItem, - configuration, - aggregateOperation, - userTimezone, - userFirstDayOfTheWeek, - ], + const groupByField = objectMetadataItem?.fields?.find( + (field) => field.id === configuration.groupByFieldMetadataId, + ); + + const selectFieldOptions = + isDefined(groupByField) && + (groupByField.type === FieldMetadataType.SELECT || + groupByField.type === FieldMetadataType.MULTI_SELECT) + ? groupByField.options + : null; + + const configurationColor = parseGraphColor(configuration.color); + + const colorMode = determineGraphColorMode({ + configurationColor, + selectFieldOptions, + }); + + const chartData = queryData?.pieChartData?.data?.map( + (item: PieChartDataItemWithColor): PieChartDataItemWithColor => { + const rawValue = formattedToRawLookup.get(item.id); + + const itemColor = determineChartItemColor({ + configurationColor, + selectOptions: selectFieldOptions, + rawValue: isString(rawValue) ? rawValue : undefined, + }); + + return { + id: item.id, + value: item.value, + color: itemColor, + }; + }, ); return { - ...transformedData, - objectMetadataItem, + data: chartData, + showLegend: configuration.displayLegend ?? true, showDataLabels: configuration.displayDataLabel ?? false, showCenterMetric: configuration.showCenterMetric ?? true, + hasTooManyGroups: queryData?.pieChartData?.hasTooManyGroups ?? false, + colorMode, + formattedToRawLookup, + objectMetadataItem, loading, error, }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts index edec855d09..2b0d8568d8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData.ts @@ -1,5 +1,5 @@ import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage'; import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState'; @@ -10,7 +10,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { useMemo } from 'react'; type UsePieChartDataProps = { - data: PieChartDataItem[]; + data: PieChartDataItemWithColor[]; colorRegistry: GraphColorRegistry; colorMode: GraphColorMode; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState.ts index 518b6cfd27..7f58f52d96 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState.ts @@ -1,10 +1,10 @@ +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; import { type ComputedDatum } from '@nivo/pie'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; export const graphWidgetPieTooltipComponentState = createComponentState<{ - datum: ComputedDatum; + datum: ComputedDatum; offsetLeft: number; offsetTop: number; } | null>({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts index 3689cfd93c..6b2ba31942 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem.ts @@ -1,7 +1,6 @@ import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; +import { type PieChartDataItem } from '~/generated/graphql'; -export type PieChartDataItem = { - id: string; - value: number; +export type PieChartDataItemWithColor = PieChartDataItem & { color?: GraphColor; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts index 49ac165093..f8b3d3df7d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData.ts @@ -1,7 +1,7 @@ -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; -export type PieChartEnrichedData = PieChartDataItem & { +export type PieChartEnrichedData = PieChartDataItemWithColor & { colorScheme: GraphColorScheme; percentage: number; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts new file mode 100644 index 0000000000..1651f52697 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/calculatePieChartPercentage.test.ts @@ -0,0 +1,61 @@ +import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage'; + +describe('calculatePieChartPercentage', () => { + describe('valid calculations', () => { + it('should calculate correct percentage for positive values', () => { + expect(calculatePieChartPercentage(25, 100)).toBe(25); + }); + + it('should calculate 100% when value equals total', () => { + expect(calculatePieChartPercentage(100, 100)).toBe(100); + }); + + it('should calculate percentage for decimal values', () => { + expect(calculatePieChartPercentage(1, 3)).toBeCloseTo(33.33, 1); + }); + + it('should handle small fractions', () => { + expect(calculatePieChartPercentage(1, 1000)).toBe(0.1); + }); + + it('should handle large values', () => { + expect(calculatePieChartPercentage(500000, 1000000)).toBe(50); + }); + }); + + describe('edge cases', () => { + it('should return 0 when totalValue is 0', () => { + expect(calculatePieChartPercentage(50, 0)).toBe(0); + }); + + it('should return 0 when value is 0', () => { + expect(calculatePieChartPercentage(0, 100)).toBe(0); + }); + + it('should return 0 when both values are 0', () => { + expect(calculatePieChartPercentage(0, 0)).toBe(0); + }); + + it('should handle negative values', () => { + expect(calculatePieChartPercentage(-25, 100)).toBe(-25); + }); + + it('should return 0 when totalValue is negative', () => { + expect(calculatePieChartPercentage(25, -100)).toBe(0); + }); + }); + + describe('NaN handling', () => { + it('should return NaN when value is NaN', () => { + expect(calculatePieChartPercentage(NaN, 100)).toBeNaN(); + }); + + it('should return NaN when totalValue is NaN', () => { + expect(calculatePieChartPercentage(50, NaN)).toBeNaN(); + }); + + it('should return NaN when both values are NaN', () => { + expect(calculatePieChartPercentage(NaN, NaN)).toBeNaN(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartFormattedValue.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartFormattedValue.test.ts new file mode 100644 index 0000000000..6ad9236f39 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartFormattedValue.test.ts @@ -0,0 +1,206 @@ +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; +import { getPieChartFormattedValue } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { type ComputedDatum } from '@nivo/pie'; + +describe('getPieChartFormattedValue', () => { + const mockColorScheme: GraphColorScheme = { + name: 'blue', + solid: '#solidBlue', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + const mockEnrichedData: PieChartEnrichedData[] = [ + { + id: 'slice1', + value: 30, + percentage: 30, + colorScheme: mockColorScheme, + }, + { + id: 'slice2', + value: 50, + percentage: 50, + colorScheme: mockColorScheme, + }, + { + id: 'slice3', + value: 20, + percentage: 20, + colorScheme: mockColorScheme, + }, + ]; + + const createMockDatum = ( + id: string, + ): ComputedDatum => + ({ + id, + value: 0, + }) as unknown as ComputedDatum; + + const defaultFormatOptions = { + displayType: 'number' as const, + }; + + describe('matching datum to enriched data', () => { + it('should return formatted value when datum matches enriched data', () => { + const datum = createMockDatum('slice1'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result).not.toBeNull(); + }); + + it('should return null when datum does not match any enriched data', () => { + const datum = createMockDatum('nonexistent'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result).toBeNull(); + }); + }); + + describe('percentage display type', () => { + it('should format as percentage when displayType is percentage', () => { + const datum = createMockDatum('slice1'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: mockEnrichedData, + formatOptions: { displayType: 'percentage' }, + displayType: 'percentage', + }); + + expect(result).toContain('%'); + }); + + it('should use the item percentage for percentage display', () => { + const datum = createMockDatum('slice2'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: mockEnrichedData, + formatOptions: { displayType: 'percentage' }, + displayType: 'percentage', + }); + + expect(result).toBeDefined(); + expect(result).toContain('%'); + }); + }); + + describe('number display type', () => { + it('should include both value and percentage when not percentage display', () => { + const datum = createMockDatum('slice1'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result).toContain('30'); + expect(result).toContain('%'); + }); + + it('should format percentage to one decimal place', () => { + const enrichedDataWithDecimal: PieChartEnrichedData[] = [ + { + id: 'slice1', + value: 33, + percentage: 33.333, + colorScheme: mockColorScheme, + }, + ]; + + const datum = createMockDatum('slice1'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: enrichedDataWithDecimal, + formatOptions: defaultFormatOptions, + }); + + expect(result).toContain('33.3%'); + }); + }); + + describe('edge cases', () => { + it('should handle zero value', () => { + const enrichedDataWithZero: PieChartEnrichedData[] = [ + { + id: 'zero', + value: 0, + percentage: 0, + colorScheme: mockColorScheme, + }, + ]; + + const datum = createMockDatum('zero'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: enrichedDataWithZero, + formatOptions: defaultFormatOptions, + }); + + expect(result).toContain('0'); + }); + + it('should handle 100% value', () => { + const enrichedDataWith100: PieChartEnrichedData[] = [ + { + id: 'full', + value: 100, + percentage: 100, + colorScheme: mockColorScheme, + }, + ]; + + const datum = createMockDatum('full'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: enrichedDataWith100, + formatOptions: defaultFormatOptions, + }); + + expect(result).toContain('100'); + }); + + it('should return null when enrichedData is empty', () => { + const datum = createMockDatum('slice1'); + + const result = getPieChartFormattedValue({ + datum, + enrichedData: [], + formatOptions: defaultFormatOptions, + }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartTooltipData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartTooltipData.test.ts new file mode 100644 index 0000000000..467119d767 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/getPieChartTooltipData.test.ts @@ -0,0 +1,208 @@ +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; +import { getPieChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { type ComputedDatum } from '@nivo/pie'; + +describe('getPieChartTooltipData', () => { + const mockColorScheme: GraphColorScheme = { + name: 'blue', + solid: '#solid', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }; + + const mockEnrichedData: PieChartEnrichedData[] = [ + { + id: 'Product A', + value: 500, + percentage: 50, + colorScheme: mockColorScheme, + }, + { + id: 'Product B', + value: 300, + percentage: 30, + colorScheme: { ...mockColorScheme, solid: '#solidB' }, + }, + { + id: 'Product C', + value: 200, + percentage: 20, + colorScheme: { ...mockColorScheme, solid: '#solidC' }, + }, + ]; + + const createMockDatum = ( + id: string, + ): ComputedDatum => + ({ + id, + value: 0, + }) as unknown as ComputedDatum; + + const defaultFormatOptions = { + displayType: 'number' as const, + }; + + describe('tooltip item generation', () => { + it('should return tooltip data when datum matches enriched data', () => { + const datum = createMockDatum('Product A'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result).not.toBeNull(); + expect(result?.tooltipItem).toBeDefined(); + }); + + it('should include correct tooltip item properties', () => { + const datum = createMockDatum('Product A'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result?.tooltipItem.key).toBe('Product A'); + expect(result?.tooltipItem.label).toBe('Product A'); + expect(result?.tooltipItem.value).toBe(500); + expect(result?.tooltipItem.dotColor).toBe('#solid'); + }); + + it('should use the correct color for different items', () => { + const datumB = createMockDatum('Product B'); + + const result = getPieChartTooltipData({ + datum: datumB, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result?.tooltipItem.dotColor).toBe('#solidB'); + }); + }); + + describe('formatted value', () => { + it('should include formatted value in tooltip item', () => { + const datum = createMockDatum('Product A'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result?.tooltipItem.formattedValue).toBeDefined(); + expect(typeof result?.tooltipItem.formattedValue).toBe('string'); + }); + + it('should format value based on displayType', () => { + const datum = createMockDatum('Product A'); + + const resultNumber = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: { displayType: 'number' }, + }); + + const resultPercentage = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: { displayType: 'percentage' }, + displayType: 'percentage', + }); + + expect(resultNumber?.tooltipItem.formattedValue).not.toBe( + resultPercentage?.tooltipItem.formattedValue, + ); + }); + }); + + describe('null returns', () => { + it('should return null when datum does not match any enriched data', () => { + const datum = createMockDatum('Unknown Product'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: mockEnrichedData, + formatOptions: defaultFormatOptions, + }); + + expect(result).toBeNull(); + }); + + it('should return null when enrichedData is empty', () => { + const datum = createMockDatum('Product A'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: [], + formatOptions: defaultFormatOptions, + }); + + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should handle item with zero value', () => { + const enrichedDataWithZero: PieChartEnrichedData[] = [ + { + id: 'Zero Item', + value: 0, + percentage: 0, + colorScheme: mockColorScheme, + }, + ]; + + const datum = createMockDatum('Zero Item'); + + const result = getPieChartTooltipData({ + datum, + enrichedData: enrichedDataWithZero, + formatOptions: defaultFormatOptions, + }); + + expect(result?.tooltipItem.value).toBe(0); + }); + + it('should handle special characters in id', () => { + const enrichedDataWithSpecialChars: PieChartEnrichedData[] = [ + { + id: 'Item & Special ', + value: 100, + percentage: 100, + colorScheme: mockColorScheme, + }, + ]; + + const datum = createMockDatum('Item & Special '); + + const result = getPieChartTooltipData({ + datum, + enrichedData: enrichedDataWithSpecialChars, + formatOptions: defaultFormatOptions, + }); + + expect(result?.tooltipItem.key).toBe('Item & Special '); + expect(result?.tooltipItem.label).toBe('Item & Special '); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts deleted file mode 100644 index 09b780203c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant'; -import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData'; -import { FirstDayOfTheWeek } from 'twenty-shared/types'; -import { - AggregateOperations, - FieldMetadataType, -} from '~/generated-metadata/graphql'; -import { - WidgetConfigurationType, - type PieChartConfiguration, -} from '~/generated/graphql'; - -jest.mock( - '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues', - () => ({ - formatPrimaryDimensionValues: jest.fn(), - }), -); - -const { formatPrimaryDimensionValues } = jest.requireMock( - '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues', -) as { formatPrimaryDimensionValues: jest.Mock }; - -describe('transformGroupByDataToPieChartData', () => { - const userTimezone = 'Europe/Paris'; - it('keeps null group buckets aligned with their aggregate values', () => { - const groupByField = { - id: 'group-by-field', - name: 'status', - type: FieldMetadataType.TEXT, - label: 'Status', - }; - - const aggregateField = { - id: 'aggregate-field', - name: 'id', - type: FieldMetadataType.UUID, - label: 'Id', - }; - - const objectMetadataItem = { - id: 'obj-1', - nameSingular: 'company', - namePlural: 'companies', - fields: [groupByField, aggregateField], - } as any; - - const configuration = { - __typename: 'PieChartConfiguration', - aggregateFieldMetadataId: aggregateField.id, - aggregateOperation: AggregateOperations.COUNT, - configurationType: WidgetConfigurationType.PIE_CHART, - groupByFieldMetadataId: groupByField.id, - displayLegend: true, - } as PieChartConfiguration; - - const groupByData = { - companiesGroupBy: [ - { groupByDimensionValues: [null], COUNT: 2 }, - { groupByDimensionValues: ['Active'], COUNT: 5 }, - ], - }; - - formatPrimaryDimensionValues.mockReturnValue([ - { - formattedPrimaryDimensionValue: 'Not Set', - rawPrimaryDimensionValue: null, - }, - { - formattedPrimaryDimensionValue: 'Active', - rawPrimaryDimensionValue: 'Active', - }, - ]); - - const result = transformGroupByDataToPieChartData({ - groupByData, - objectMetadataItem, - configuration, - aggregateOperation: 'COUNT', - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.data).toEqual([ - { id: 'Not Set', value: 2, color: undefined }, - { id: 'Active', value: 5, color: undefined }, - ]); - expect(result.formattedToRawLookup.get('Not Set')).toBeNull(); - expect(result.formattedToRawLookup.get('Active')).toBe('Active'); - expect(result.hasTooManyGroups).toBe(false); - }); - - it('honors color configuration, hides legend, and flags too many groups', () => { - const groupByField = { - id: 'group-by-field', - name: 'status', - type: FieldMetadataType.TEXT, - label: 'Status', - }; - - const aggregateField = { - id: 'aggregate-field', - name: 'id', - type: FieldMetadataType.UUID, - label: 'Id', - }; - - const objectMetadataItem = { - id: 'obj-1', - nameSingular: 'company', - namePlural: 'companies', - fields: [groupByField, aggregateField], - } as any; - - const configuration = { - __typename: 'PieChartConfiguration', - aggregateFieldMetadataId: aggregateField.id, - aggregateOperation: AggregateOperations.COUNT, - configurationType: WidgetConfigurationType.PIE_CHART, - groupByFieldMetadataId: groupByField.id, - displayLegend: false, - color: 'red', - } as PieChartConfiguration; - - const totalResults = PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + 1; - const groupByData = { - companiesGroupBy: Array.from({ length: totalResults }).map( - (_unused, index) => ({ - groupByDimensionValues: [`Group ${index}`], - COUNT: index + 1, - }), - ), - }; - - formatPrimaryDimensionValues.mockReturnValue( - Array.from({ length: totalResults }).map((_unused, index) => ({ - formattedPrimaryDimensionValue: `Group ${index}`, - rawPrimaryDimensionValue: `Group ${index}`, - })), - ); - - const result = transformGroupByDataToPieChartData({ - groupByData, - objectMetadataItem, - configuration, - aggregateOperation: 'COUNT', - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result.showLegend).toBe(false); - expect(result.hasTooManyGroups).toBe(true); - expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES); - expect(result.data[0]).toEqual({ - id: 'Group 0', - value: 1, - color: 'red', - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue.ts index 408a6d6bcc..545b99ebb0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue.ts @@ -5,11 +5,11 @@ import { import { type ComputedDatum } from '@nivo/pie'; import { isDefined } from 'twenty-shared/utils'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; type GetPieChartFormattedValueParams = { - datum: ComputedDatum; + datum: ComputedDatum; enrichedData: PieChartEnrichedData[]; formatOptions: GraphValueFormatOptions; displayType?: string; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData.ts index 9a9f7d95ca..72c5376d3d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData.ts @@ -1,5 +1,5 @@ import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData'; import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { type ComputedDatum } from '@nivo/pie'; @@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils'; import { getPieChartFormattedValue } from './getPieChartFormattedValue'; type GetPieChartTooltipDataParams = { - datum: ComputedDatum; + datum: ComputedDatum; enrichedData: PieChartEnrichedData[]; formatOptions: GraphValueFormatOptions; displayType?: string; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts deleted file mode 100644 index 91da9650c7..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; -import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant'; -import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; -import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; -import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor'; -import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor'; -import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults'; -import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded'; -import { type FirstDayOfTheWeek } from 'twenty-shared/types'; -import { isDefined, isFieldMetadataSelectKind } from 'twenty-shared/utils'; -import { type PieChartConfiguration } from '~/generated/graphql'; - -type TransformGroupByDataToPieChartDataParams = { - groupByData: Record | null | undefined; - objectMetadataItem: ObjectMetadataItem; - configuration: PieChartConfiguration; - aggregateOperation: string; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -type TransformGroupByDataToPieChartDataResult = { - data: PieChartDataItem[]; - showLegend: boolean; - hasTooManyGroups: boolean; - formattedToRawLookup: Map; - colorMode: GraphColorMode; -}; - -const EMPTY_PIE_CHART_RESULT: TransformGroupByDataToPieChartDataResult = { - data: [], - showLegend: true, - hasTooManyGroups: false, - formattedToRawLookup: new Map(), - colorMode: 'automaticPalette', -}; - -export const transformGroupByDataToPieChartData = ({ - groupByData, - objectMetadataItem, - configuration, - aggregateOperation, - userTimezone, - firstDayOfTheWeek, -}: TransformGroupByDataToPieChartDataParams): TransformGroupByDataToPieChartDataResult => { - if (!isDefined(groupByData)) { - return EMPTY_PIE_CHART_RESULT; - } - - const groupByField = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.groupByFieldMetadataId, - ); - - const aggregateField = objectMetadataItem.fields.find( - (field: FieldMetadataItem) => - field.id === configuration.aggregateFieldMetadataId, - ); - - if (!isDefined(groupByField) || !isDefined(aggregateField)) { - return EMPTY_PIE_CHART_RESULT; - } - - const queryName = getGroupByQueryResultGqlFieldName(objectMetadataItem); - const rawResults = groupByData[queryName]; - - if (!isDefined(rawResults) || !Array.isArray(rawResults)) { - return EMPTY_PIE_CHART_RESULT; - } - - const filteredResults = configuration.hideEmptyCategory - ? rawResults.filter((result) => - isDefined(result.groupByDimensionValues?.[0]), - ) - : rawResults; - - const { processedDataPoints, formattedToRawLookup } = - processOneDimensionalGroupByResults({ - rawResults: filteredResults, - groupByFieldX: groupByField, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName: configuration.groupBySubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) - const limitedProcessedDataPoints = processedDataPoints.slice( - 0, - PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, - ); - - type PieChartDataItemWithRawValue = PieChartDataItem & { - rawValue: string | null | undefined; - }; - - const unsortedDataWithRawValues: PieChartDataItemWithRawValue[] = - limitedProcessedDataPoints.map(({ xValue, rawXValue, aggregateValue }) => { - const rawValueString = isDefined(rawXValue) ? String(rawXValue) : null; - - return { - id: xValue, - value: aggregateValue, - color: determineChartItemColor({ - configurationColor: parseGraphColor(configuration.color), - selectOptions: isFieldMetadataSelectKind(groupByField.type) - ? groupByField.options - : undefined, - rawValue: rawValueString, - }), - rawValue: rawValueString, - }; - }); - - const sortedDataWithRawValues = sortChartDataIfNeeded({ - data: unsortedDataWithRawValues, - orderBy: configuration.orderBy ?? undefined, - manualSortOrder: configuration.manualSortOrder ?? undefined, - formattedToRawLookup, - getFieldValue: (item) => item.id, - getNumericValue: (item) => item.value, - selectFieldOptions: isFieldMetadataSelectKind(groupByField.type) - ? groupByField.options - : undefined, - }); - - const data: PieChartDataItem[] = sortedDataWithRawValues.map( - ({ rawValue: _rawValue, ...item }) => item, - ); - - const showLegend = configuration.displayLegend ?? true; - - const colorMode = determineGraphColorMode({ - configurationColor: configuration.color, - selectFieldOptions: isFieldMetadataSelectKind(groupByField.type) - ? groupByField.options - : undefined, - }); - - return { - data, - showLegend, - hasTooManyGroups: - filteredResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, - formattedToRawLookup, - colorMode, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/barChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/barChartData.ts new file mode 100644 index 0000000000..a80958ba9d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/barChartData.ts @@ -0,0 +1,23 @@ +import { gql } from '@apollo/client'; + +export const BAR_CHART_DATA = gql` + query BarChartData($input: BarChartDataInput!) { + barChartData(input: $input) { + data + indexBy + keys + series { + key + label + } + xAxisLabel + yAxisLabel + showLegend + showDataLabels + layout + groupMode + hasTooManyGroups + formattedToRawLookup + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/lineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/lineChartData.ts new file mode 100644 index 0000000000..dc67f59a8e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/lineChartData.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; + +export const LINE_CHART_DATA = gql` + query LineChartData($input: LineChartDataInput!) { + lineChartData(input: $input) { + series { + id + label + data { + x + y + } + } + xAxisLabel + yAxisLabel + showLegend + showDataLabels + hasTooManyGroups + formattedToRawLookup + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/pieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/pieChartData.ts new file mode 100644 index 0000000000..8e12fc8a4f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphql/queries/pieChartData.ts @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client'; + +export const PIE_CHART_DATA = gql` + query PieChartData($input: PieChartDataInput!) { + pieChartData(input: $input) { + data { + id + value + } + showLegend + showDataLabels + showCenterMetric + hasTooManyGroups + formattedToRawLookup + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts deleted file mode 100644 index c3ec4daff9..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType'; -import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery'; -import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields'; -import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon'; -import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration'; -import { generateGroupByQueryVariablesFromBarOrLineChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration'; -import { generateGroupByQueryVariablesFromPieChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration'; -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { useQuery } from '@apollo/client'; -import { useMemo } from 'react'; -import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; -import { - type BarChartConfiguration, - type LineChartConfiguration, -} from '~/generated/graphql'; - -export const useGraphWidgetGroupByQuery = ({ - objectMetadataItemId, - configuration, - limit = DEFAULT_NUMBER_OF_GROUPS_LIMIT, -}: { - objectMetadataItemId: string; - configuration: GroupByChartConfiguration; - limit?: number; -}) => { - const { calendarStartDay } = useDateTimeFormat(); - - const { objectMetadataItem, aggregateField, gqlOperationFilter } = - useGraphWidgetQueryCommon({ - objectMetadataItemId, - configuration, - }); - - const { userTimezone } = useUserTimezone(); - - const { objectMetadataItems } = useObjectMetadataItems(); - - if (!isDefined(aggregateField)) { - throw new Error('Aggregate field not found'); - } - - const availableAggregations = useMemo( - () => - getAvailableAggregationsFromObjectFields( - objectMetadataItem.readableFields, - ), - [objectMetadataItem.readableFields], - ); - - const aggregateOperation = - availableAggregations[aggregateField.name]?.[ - configuration.aggregateOperation - ]; - - if (!isDefined(aggregateOperation)) { - throw new Error('Aggregate operation not found'); - } - - const groupByQueryVariables = isWidgetConfigurationOfType( - configuration, - 'PieChartConfiguration', - ) - ? generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem, - objectMetadataItems, - chartConfiguration: configuration, - aggregateOperation: aggregateOperation, - limit, - firstDayOfTheWeek: calendarStartDay, - userTimeZone: userTimezone, - }) - : generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem, - objectMetadataItems, - chartConfiguration: configuration as - | BarChartConfiguration - | LineChartConfiguration, - aggregateOperation: aggregateOperation, - limit, - firstDayOfTheWeek: calendarStartDay, - userTimeZone: userTimezone, - }); - - const variables = { - ...groupByQueryVariables, - filter: gqlOperationFilter, - }; - - const groupByAggregateQuery = generateGroupByAggregateQuery({ - objectMetadataItem, - aggregateOperationGqlFields: [aggregateOperation], - }); - - const apolloCoreClient = useApolloCoreClient(); - - const { data, loading, error, refetch } = useQuery(groupByAggregateQuery, { - client: apolloCoreClient, - variables, - }); - - return { - data, - loading, - error, - refetch, - aggregateOperation: aggregateOperation, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts deleted file mode 100644 index 92133e4883..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphWidgetTooltipContent.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip'; - -export type GraphWidgetTooltipContent = { - items: GraphWidgetTooltipItem[]; - indexLabel?: string; - highlightedKey?: string; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts deleted file mode 100644 index 9256026029..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { - type BarChartConfiguration, - type LineChartConfiguration, - type PieChartConfiguration, -} from '~/generated-metadata/graphql'; - -export type GroupByChartConfiguration = - | BarChartConfiguration - | LineChartConfiguration - | PieChartConfiguration; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap deleted file mode 100644 index 8ab2595653..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap +++ /dev/null @@ -1,37 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-01-15 1`] = `"Q1 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-02-15 1`] = `"Q1 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-03-15 1`] = `"Q1 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-04-15 1`] = `"Q2 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-05-15 1`] = `"Q2 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-06-15 1`] = `"Q2 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-07-15 1`] = `"Q3 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-08-15 1`] = `"Q3 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-09-15 1`] = `"Q3 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-10-15 1`] = `"Q4 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-11-15 1`] = `"Q4 2024"`; - -exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-12-15 1`] = `"Q4 2024"`; - -exports[`formatDateByGranularity should format date for DAY granularity for 2024-03-20 1`] = `"Mar 20, 2024"`; - -exports[`formatDateByGranularity should format date for MONTH granularity for 2024-03-20 1`] = `"March 2024"`; - -exports[`formatDateByGranularity should format date for NONE granularity for 2024-03-20 1`] = `"3/20/2024"`; - -exports[`formatDateByGranularity should format date for QUARTER granularity for 2024-03-20 1`] = `"Q1 2024"`; - -exports[`formatDateByGranularity should format date for WEEK granularity for 2024-03-20 1`] = `"Mar 18 - 24, 2024"`; - -exports[`formatDateByGranularity should format date for YEAR granularity for 2024-03-20 1`] = `"2024"`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap deleted file mode 100644 index 83fffce695..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap +++ /dev/null @@ -1,210 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with secondary axis 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - { - "ownerId": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with composite field 1`] = ` -{ - "groupBy": [ - { - "name": { - "firstName": true, - }, - }, - ], - "orderBy": [ - { - "name": { - "firstName": "AscNullsLast", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with date field and granularity 1`] = ` -{ - "groupBy": [ - { - "createdAt": { - "granularity": "MONTH", - "timeZone": "Europe/Paris", - }, - }, - ], - "orderBy": [ - { - "createdAt": { - "granularity": "MONTH", - "orderBy": "AscNullsLast", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with two groupBy fields 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - { - "ownerId": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart Configuration should generate variables with composite field 1`] = ` -{ - "groupBy": [ - { - "name": { - "firstName": true, - }, - }, - ], - "orderBy": [ - { - "name": { - "firstName": "AscNullsLast", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart Configuration should generate variables with date field and granularity 1`] = ` -{ - "groupBy": [ - { - "createdAt": { - "granularity": "MONTH", - "timeZone": "Europe/Paris", - }, - }, - ], - "orderBy": [ - { - "createdAt": { - "granularity": "MONTH", - "orderBy": "AscNullsLast", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart Configuration should generate variables with secondary axis 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - { - "ownerId": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart Configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart Configuration should generate variables with two groupBy fields (multi-series) 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], - "orderBy": [ - { - "stage": "AscNullsLast", - }, - { - "ownerId": "AscNullsLast", - }, - ], -} -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap deleted file mode 100644 index 8f29f86932..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap +++ /dev/null @@ -1,64 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with composite field 1`] = ` -{ - "groupBy": [ - { - "name": { - "firstName": true, - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with date field and granularity 1`] = ` -{ - "groupBy": [ - { - "createdAt": { - "granularity": "MONTH", - "timeZone": "Europe/Paris", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with limit 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], - "limit": 10, -} -`; - -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with orderBy 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], - "orderBy": [ - { - "aggregate": { - "count": "AscNullsLast", - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], -} -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts deleted file mode 100644 index 91d5204946..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; -import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -const userTimezone = 'Europe/Paris'; - -describe('buildGroupByFieldObject', () => { - it('should return field with Id suffix for relation fields without subFieldName', () => { - const field = { - name: 'company', - type: FieldMetadataType.RELATION, - } as any; - - const result = buildGroupByFieldObject({ field }); - - expect(result).toEqual({ companyId: true }); - }); - - it('should return nested object for relation field with subFieldName', () => { - const field = { - name: 'company', - type: FieldMetadataType.RELATION, - } as any; - - const result = buildGroupByFieldObject({ field, subFieldName: 'name' }); - - expect(result).toEqual({ company: { name: true } }); - }); - - it('should return deeply nested object for relation with composite subfield', () => { - const field = { - name: 'company', - type: FieldMetadataType.RELATION, - } as any; - - const result = buildGroupByFieldObject({ - field, - subFieldName: 'address.addressCity', - }); - - expect(result).toEqual({ - company: { address: { addressCity: true } }, - }); - }); - - it('should return date granularity for relation date field', () => { - const field = { - name: 'company', - type: FieldMetadataType.RELATION, - } as any; - - const result = buildGroupByFieldObject({ - field, - subFieldName: 'createdAt', - dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - isNestedDateField: true, - timeZone: userTimezone, - }); - - expect(result).toEqual({ - company: { - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.MONTH, - timeZone: 'Europe/Paris', - }, - }, - }); - }); - - it('should return nested object for composite fields with subfield', () => { - const field = { - name: 'name', - type: FieldMetadataType.FULL_NAME, - } as any; - - const result = buildGroupByFieldObject({ - field, - subFieldName: 'firstName', - }); - - expect(result).toEqual({ - name: { - firstName: true, - }, - }); - }); - - it('should throw error for composite field without subfield', () => { - const field = { - name: 'name', - type: FieldMetadataType.FULL_NAME, - } as any; - - expect(() => buildGroupByFieldObject({ field })).toThrow( - 'Composite field name requires a subfield to be specified', - ); - }); - - it('should return field with default granularity for DATE field', () => { - const field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ field, 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 field = { - name: 'updatedAt', - type: FieldMetadataType.DATE_TIME, - } as any; - - const result = buildGroupByFieldObject({ field, timeZone: 'Europe/Paris' }); - - expect(result).toEqual({ - updatedAt: { - granularity: ObjectRecordGroupByDateGranularity.DAY, - timeZone: 'Europe/Paris', - }, - }); - }); - - it('should return field with custom granularity for date field', () => { - const field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ - field, - dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - timeZone: 'Europe/Paris', - }); - - expect(result).toEqual({ - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.MONTH, - timeZone: 'Europe/Paris', - }, - }); - }); - - it('should return simple field object for regular fields', () => { - const field = { - name: 'status', - type: FieldMetadataType.TEXT, - } as any; - - const result = buildGroupByFieldObject({ field }); - - expect(result).toEqual({ status: true }); - }); - - it('should include weekStartDay for WEEK granularity with MONDAY', () => { - const field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ - field, - 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 field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ - field, - 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 field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ - field, - 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 field = { - name: 'createdAt', - type: FieldMetadataType.DATE, - } as any; - - const result = buildGroupByFieldObject({ - field, - dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - firstDayOfTheWeek: CalendarStartDay.MONDAY, - timeZone: userTimezone, - }); - - expect(result).toEqual({ - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.MONTH, - timeZone: userTimezone, - }, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeAggregateNumericValueForGraph.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeAggregateNumericValueForGraph.test.ts deleted file mode 100644 index dbe410befc..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeAggregateNumericValueForGraph.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { computeAggregateNumericValueForGraph } from '@/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph'; - -describe('computeAggregateNumericValueForGraph', () => { - it('should throw error for empty data', () => { - expect(() => - computeAggregateNumericValueForGraph({ - data: {}, - objectMetadataItem: { fields: [] } as any, - }), - ).toThrow('Empty aggregate records data'); - }); - - it('should return count value for COUNT operation', () => { - const result = computeAggregateNumericValueForGraph({ - data: { status: { COUNT: 25 } }, - objectMetadataItem: { - fields: [{ id: 'field-1', name: 'status' }], - } as any, - fieldMetadataId: 'field-1', - aggregateOperation: AggregateOperations.COUNT, - }); - - expect(result).toBe(25); - }); - - it('should multiply by 100 for percentage operations', () => { - const result = computeAggregateNumericValueForGraph({ - data: { status: { PERCENTAGE_EMPTY: 0.25 } }, - objectMetadataItem: { - fields: [{ id: 'field-1', name: 'status' }], - } as any, - fieldMetadataId: 'field-1', - aggregateOperation: AggregateOperations.PERCENTAGE_EMPTY, - }); - - expect(result).toBe(25); - }); - - it('should return numeric value for NUMBER field', () => { - const result = computeAggregateNumericValueForGraph({ - data: { amount: { SUM: 1000 } }, - objectMetadataItem: { - fields: [ - { id: 'field-1', name: 'amount', type: FieldMetadataType.NUMBER }, - ], - } as any, - fieldMetadataId: 'field-1', - aggregateOperation: AggregateOperations.SUM, - }); - - expect(result).toBe(1000); - }); - - it('should return timestamp for DATE field', () => { - const dateString = '2024-01-15'; - const result = computeAggregateNumericValueForGraph({ - data: { createdAt: { MIN: dateString } }, - objectMetadataItem: { - fields: [ - { id: 'field-1', name: 'createdAt', type: FieldMetadataType.DATE }, - ], - } as any, - fieldMetadataId: 'field-1', - aggregateOperation: AggregateOperations.MIN, - }); - - expect(result).toBe(new Date(dateString).getTime()); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts deleted file mode 100644 index cefbcf33d1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; - -describe('createEmptySelectGroup', () => { - it('creates record with correct groupByDimensionValues', () => { - const result = createEmptySelectGroup(['A'], ['count']); - - expect(result.groupByDimensionValues).toEqual(['A']); - }); - - it('initializes all aggregateKeys to 0', () => { - const result = createEmptySelectGroup(['A'], ['count']); - - expect(result.count).toBe(0); - }); - - it('handles empty aggregateKeys array', () => { - const result = createEmptySelectGroup(['A'], []); - - expect(result).toEqual({ - groupByDimensionValues: ['A'], - }); - }); - - it('handles multiple aggregate keys', () => { - const result = createEmptySelectGroup(['A'], ['count', 'sum', 'avg']); - - expect(result).toEqual({ - groupByDimensionValues: ['A'], - count: 0, - sum: 0, - avg: 0, - }); - }); - - it('handles null values in dimensionValues', () => { - const result = createEmptySelectGroup(['A', null], ['count']); - - expect(result.groupByDimensionValues).toEqual(['A', null]); - expect(result.count).toBe(0); - }); - - it('handles multiple dimension values', () => { - const result = createEmptySelectGroup(['A', 'X', 'Y'], ['count']); - - expect(result.groupByDimensionValues).toEqual(['A', 'X', 'Y']); - expect(result.count).toBe(0); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/determineGraphColorMode.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/determineGraphColorMode.test.ts new file mode 100644 index 0000000000..17263521e8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/determineGraphColorMode.test.ts @@ -0,0 +1,102 @@ +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode'; + +describe('determineGraphColorMode', () => { + const mockSelectOptions: FieldMetadataItemOption[] = [ + { id: '1', value: 'ACTIVE', label: 'Active', position: 0, color: 'green' }, + { + id: '2', + value: 'INACTIVE', + label: 'Inactive', + position: 1, + color: 'red', + }, + ]; + + describe('explicitSingleColor mode', () => { + it('should return explicitSingleColor when configurationColor is set and not "auto"', () => { + expect( + determineGraphColorMode({ + configurationColor: 'blue', + selectFieldOptions: mockSelectOptions, + }), + ).toBe('explicitSingleColor'); + }); + + it('should return explicitSingleColor for any valid color string', () => { + expect( + determineGraphColorMode({ + configurationColor: 'red', + selectFieldOptions: null, + }), + ).toBe('explicitSingleColor'); + }); + }); + + describe('selectFieldOptionColors mode', () => { + it('should return selectFieldOptionColors when configurationColor is "auto" and options exist', () => { + expect( + determineGraphColorMode({ + configurationColor: 'auto', + selectFieldOptions: mockSelectOptions, + }), + ).toBe('selectFieldOptionColors'); + }); + + it('should return selectFieldOptionColors when configurationColor is null and options exist', () => { + expect( + determineGraphColorMode({ + configurationColor: null, + selectFieldOptions: mockSelectOptions, + }), + ).toBe('selectFieldOptionColors'); + }); + + it('should return selectFieldOptionColors when configurationColor is undefined and options exist', () => { + expect( + determineGraphColorMode({ + configurationColor: undefined, + selectFieldOptions: mockSelectOptions, + }), + ).toBe('selectFieldOptionColors'); + }); + }); + + describe('automaticPalette mode', () => { + it('should return automaticPalette when configurationColor is "auto" and no options', () => { + expect( + determineGraphColorMode({ + configurationColor: 'auto', + selectFieldOptions: null, + }), + ).toBe('automaticPalette'); + }); + + it('should return automaticPalette when configurationColor is null and no options', () => { + expect( + determineGraphColorMode({ + configurationColor: null, + selectFieldOptions: null, + }), + ).toBe('automaticPalette'); + }); + + it('should return automaticPalette when configurationColor is undefined and no options', () => { + expect( + determineGraphColorMode({ + configurationColor: undefined, + selectFieldOptions: undefined, + }), + ).toBe('automaticPalette'); + }); + + it('should return automaticPalette when options array is empty', () => { + expect( + determineGraphColorMode({ + configurationColor: 'auto', + selectFieldOptions: [], + }), + ).toBe('automaticPalette'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts deleted file mode 100644 index 6d11e9fb24..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; - -describe('fillSelectGapsInChartData', () => { - const selectOptions = [ - { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, - { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, - { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, - ] as const; - - describe('one-dimensional data', () => { - it('returns data unchanged when selectOptions is undefined', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 5 }, - { groupByDimensionValues: ['B'], count: 3 }, - ]; - - const result = fillSelectGapsInChartData({ - data, - selectOptions: undefined, - aggregateKeys: ['count'], - }); - - expect(result).toEqual(data); - }); - - it('returns data unchanged when selectOptions is null', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 5 }, - { groupByDimensionValues: ['B'], count: 3 }, - ]; - - const result = fillSelectGapsInChartData({ - data, - selectOptions: null, - aggregateKeys: ['count'], - }); - - expect(result).toEqual(data); - }); - - it('returns data unchanged when selectOptions is empty', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 5 }, - { groupByDimensionValues: ['B'], count: 3 }, - ]; - - const result = fillSelectGapsInChartData({ - data, - selectOptions: [], - aggregateKeys: ['count'], - }); - - expect(result).toEqual(data); - }); - - it('returns empty data unchanged', () => { - const result = fillSelectGapsInChartData({ - data: [], - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toEqual([]); - }); - - it('fills missing select options with zero values', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 5 }, - { groupByDimensionValues: ['C'], count: 3 }, - ]; - - const result = fillSelectGapsInChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toHaveLength(3); - expect(result[0]).toEqual({ - groupByDimensionValues: ['A'], - count: 5, - }); - expect(result[1]).toEqual({ - groupByDimensionValues: ['B'], - count: 0, - }); - expect(result[2]).toEqual({ - groupByDimensionValues: ['C'], - count: 3, - }); - }); - }); - - describe('two-dimensional data', () => { - it('fills missing primary axis options for all secondary values', () => { - const data = [ - { groupByDimensionValues: ['A', 'X'], count: 5 }, - { groupByDimensionValues: ['C', 'X'], count: 3 }, - { groupByDimensionValues: ['A', 'Y'], count: 2 }, - ]; - - const result = fillSelectGapsInChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - hasSecondDimension: true, - }); - - 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); - - expect( - result.find( - (r) => - r.groupByDimensionValues[0] === 'B' && - r.groupByDimensionValues[1] === 'X', - ), - ).toEqual({ - groupByDimensionValues: ['B', 'X'], - count: 0, - }); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts deleted file mode 100644 index 4e9f4c9d1e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { fillSelectGapsInOneDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData'; - -describe('fillSelectGapsInOneDimensionalChartData', () => { - const selectOptions = [ - { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, - { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, - { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, - ] as const; - - it('fills missing select options with zero values', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 5 }, - { groupByDimensionValues: ['C'], count: 3 }, - ]; - - const result = fillSelectGapsInOneDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toHaveLength(3); - expect(result[0]).toEqual({ - groupByDimensionValues: ['A'], - count: 5, - }); - expect(result[1]).toEqual({ - groupByDimensionValues: ['B'], - count: 0, - }); - expect(result[2]).toEqual({ - groupByDimensionValues: ['C'], - count: 3, - }); - }); - - it('preserves existing data values', () => { - const data = [ - { groupByDimensionValues: ['A'], count: 10, sum: 100 }, - { groupByDimensionValues: ['B'], count: 20, sum: 200 }, - { groupByDimensionValues: ['C'], count: 30, sum: 300 }, - ]; - - const result = fillSelectGapsInOneDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count', 'sum'], - }); - - expect(result).toHaveLength(3); - expect(result).toEqual(data); - }); - - it('preserves selectOptions order', () => { - const data = [ - { groupByDimensionValues: ['C'], count: 3 }, - { groupByDimensionValues: ['A'], count: 5 }, - ]; - - const result = fillSelectGapsInOneDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - 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'); - }); - - it('works with multiple aggregate keys', () => { - const data = [{ groupByDimensionValues: ['A'], count: 5, sum: 100 }]; - - const result = fillSelectGapsInOneDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count', 'sum'], - }); - - expect(result).toHaveLength(3); - expect(result[1]).toEqual({ - groupByDimensionValues: ['B'], - count: 0, - sum: 0, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts deleted file mode 100644 index 00338a4381..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { fillSelectGapsInTwoDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData'; - -describe('fillSelectGapsInTwoDimensionalChartData', () => { - const selectOptions = [ - { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, - { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, - { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, - ] as const; - - it('fills missing primary axis options for all secondary values', () => { - const data = [ - { groupByDimensionValues: ['A', 'X'], count: 5 }, - { groupByDimensionValues: ['C', 'X'], count: 3 }, - { groupByDimensionValues: ['A', 'Y'], count: 2 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - 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('creates zero-value entries for missing combinations', () => { - const data = [ - { groupByDimensionValues: ['A', 'X'], count: 5 }, - { groupByDimensionValues: ['C', 'Y'], count: 3 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toHaveLength(6); - - expect( - result.find( - (r) => - r.groupByDimensionValues[0] === 'B' && - r.groupByDimensionValues[1] === 'X', - ), - ).toEqual({ - groupByDimensionValues: ['B', 'X'], - count: 0, - }); - - expect( - result.find( - (r) => - r.groupByDimensionValues[0] === 'A' && - r.groupByDimensionValues[1] === 'Y', - ), - ).toEqual({ - groupByDimensionValues: ['A', 'Y'], - count: 0, - }); - }); - - it('preserves existing combinations', () => { - const data = [ - { groupByDimensionValues: ['A', 'X'], count: 5 }, - { groupByDimensionValues: ['B', 'X'], count: 10 }, - { groupByDimensionValues: ['C', 'X'], count: 15 }, - { groupByDimensionValues: ['A', 'Y'], count: 20 }, - { groupByDimensionValues: ['B', 'Y'], count: 25 }, - { groupByDimensionValues: ['C', 'Y'], count: 30 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toHaveLength(6); - - expect( - result.find( - (r) => - r.groupByDimensionValues[0] === 'A' && - r.groupByDimensionValues[1] === 'X', - ), - ).toEqual({ - groupByDimensionValues: ['A', 'X'], - count: 5, - }); - }); - - it('preserves selectOptions order for primary axis', () => { - const data = [ - { groupByDimensionValues: ['C', 'X'], count: 3 }, - { groupByDimensionValues: ['A', 'X'], count: 5 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - 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('handles null secondary dimension values', () => { - const data = [ - { groupByDimensionValues: ['A', null], count: 5 }, - { groupByDimensionValues: ['C', null], count: 3 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - expect(result).toHaveLength(3); - - expect( - result.find( - (r) => - r.groupByDimensionValues[0] === 'B' && - r.groupByDimensionValues[1] === null, - ), - ).toEqual({ - groupByDimensionValues: ['B', null], - count: 0, - }); - }); - - it('does NOT fill missing secondary axis values', () => { - const data = [ - { groupByDimensionValues: ['A', 'X'], count: 5 }, - { groupByDimensionValues: ['A', 'Y'], count: 3 }, - ]; - - const result = fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions: [...selectOptions], - aggregateKeys: ['count'], - }); - - 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'); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts deleted file mode 100644 index 3a129f6fd0..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; - -describe('filterGroupByResults', () => { - const mockAggregateField = { - id: 'field-1', - name: 'amount', - type: FieldMetadataType.NUMBER, - label: 'Amount', - createdAt: '2024-01-01', - updatedAt: '2024-01-01', - }; - - const mockObjectMetadataItem = { - id: 'obj-1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [mockAggregateField], - } as any; - - const createMockResult = (value: number | null) => ({ - groupByDimensionValues: ['Group A'], - SUM_amount: value, - }); - - describe('rangeMin filtering', () => { - it('should filter out results below rangeMin', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1500), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMin: 1000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(2); - expect(filtered[0].SUM_amount).toBe(1500); - expect(filtered[1].SUM_amount).toBe(2500); - }); - - it('should include values equal to rangeMin', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1000), - createMockResult(1500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMin: 1000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(2); - expect(filtered[0].SUM_amount).toBe(1000); - expect(filtered[1].SUM_amount).toBe(1500); - }); - }); - - describe('rangeMax filtering', () => { - it('should filter out results above rangeMax', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1500), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMax: 2000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(2); - expect(filtered[0].SUM_amount).toBe(500); - expect(filtered[1].SUM_amount).toBe(1500); - }); - - it('should include values equal to rangeMax', () => { - const rawResults = [ - createMockResult(1500), - createMockResult(2000), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMax: 2000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(2); - expect(filtered[0].SUM_amount).toBe(1500); - expect(filtered[1].SUM_amount).toBe(2000); - }); - }); - - describe('range filtering', () => { - it('should keep only results within range', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1500), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMin: 1000, rangeMax: 2000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(1); - expect(filtered[0].SUM_amount).toBe(1500); - }); - - it('should include boundary values', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1000), - createMockResult(1500), - createMockResult(2000), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { rangeMin: 1000, rangeMax: 2000 }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toHaveLength(3); - expect(filtered[0].SUM_amount).toBe(1000); - expect(filtered[1].SUM_amount).toBe(1500); - expect(filtered[2].SUM_amount).toBe(2000); - }); - }); - - describe('no filters', () => { - it('should return all results when no filters are active', () => { - const rawResults = [ - createMockResult(500), - createMockResult(1500), - createMockResult(2500), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: {}, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toEqual(rawResults); - }); - - it('should return all results when omitNullValues is false', () => { - const rawResults = [ - createMockResult(null), - createMockResult(0), - createMockResult(100), - ]; - - const filtered = filterGroupByResults({ - rawResults, - filterOptions: { omitNullValues: false }, - aggregateField: mockAggregateField, - aggregateOperation: AggregateOperations.SUM, - aggregateOperationFromRawResult: 'SUM_amount', - objectMetadataItem: mockObjectMetadataItem, - }); - - expect(filtered).toEqual(rawResults); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts deleted file mode 100644 index 7ba15d7132..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Temporal } from 'temporal-polyfill'; -import { - FirstDayOfTheWeek, - ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; -import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity'; - -describe('formatDateByGranularity', () => { - const testDate = Temporal.PlainDate.from('2024-03-20'); - const userTimezone = 'Europe/Paris'; - - const testCases: { - granularity: - | ObjectRecordGroupByDateGranularity.DAY - | ObjectRecordGroupByDateGranularity.WEEK - | ObjectRecordGroupByDateGranularity.MONTH - | ObjectRecordGroupByDateGranularity.QUARTER - | ObjectRecordGroupByDateGranularity.YEAR - | ObjectRecordGroupByDateGranularity.NONE; - }[] = [ - { granularity: ObjectRecordGroupByDateGranularity.DAY }, - { granularity: ObjectRecordGroupByDateGranularity.WEEK }, - { granularity: ObjectRecordGroupByDateGranularity.MONTH }, - { granularity: ObjectRecordGroupByDateGranularity.QUARTER }, - { granularity: ObjectRecordGroupByDateGranularity.YEAR }, - { granularity: ObjectRecordGroupByDateGranularity.NONE }, - ]; - - it.each(testCases)( - `should format date for $granularity granularity for ${testDate.toString()}`, - ({ granularity }) => { - expect( - formatDateByGranularity( - testDate, - granularity, - userTimezone, - FirstDayOfTheWeek.MONDAY, - ), - ).toMatchSnapshot(); - }, - ); - - describe('week calculations', () => { - it('should format week within same month', () => { - const date = Temporal.PlainDate.from('2024-05-06'); - const result = formatDateByGranularity( - date, - ObjectRecordGroupByDateGranularity.WEEK, - userTimezone, - FirstDayOfTheWeek.MONDAY, - ); - expect(result).toBe('May 6 - 12, 2024'); - }); - - it('should format week crossing months', () => { - const date = Temporal.PlainDate.from('2024-05-27'); - const result = formatDateByGranularity( - date, - ObjectRecordGroupByDateGranularity.WEEK, - userTimezone, - FirstDayOfTheWeek.MONDAY, - ); - expect(result).toBe('May 27 - Jun 2, 2024'); - }); - - it('should format week crossing years', () => { - const date = Temporal.PlainDate.from('2024-12-30'); - const result = formatDateByGranularity( - date, - ObjectRecordGroupByDateGranularity.WEEK, - userTimezone, - FirstDayOfTheWeek.MONDAY, - ); - expect(result).toBe('Dec 30, 2024 - Jan 5, 2025'); - }); - }); - - describe('quarter calculations', () => { - const quarterTestCases: { - dayString: string; - granularity: ObjectRecordGroupByDateGranularity.QUARTER; - }[] = [ - { - dayString: '2024-01-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-02-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-03-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-04-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-05-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-06-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-07-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-08-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-09-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-10-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-11-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - { - dayString: '2024-12-15', - granularity: ObjectRecordGroupByDateGranularity.QUARTER, - }, - ]; - - it.each(quarterTestCases)( - 'should calculate quarter for $dayString', - ({ dayString, granularity }) => { - expect( - formatDateByGranularity( - Temporal.PlainDate.from(dayString), - granularity, - 'Europe/Paris', - FirstDayOfTheWeek.MONDAY, - ), - ).toMatchSnapshot(); - }, - ); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts deleted file mode 100644 index cff0b2e656..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; -import { - FieldMetadataType, - FirstDayOfTheWeek, - ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; - -jest.mock('@/page-layout/widgets/graph/utils/formatDimensionValue', () => ({ - formatDimensionValue: jest.fn((args: { value: unknown }) => { - return `formatted-${String(args.value)}`; - }), -})); - -const { formatDimensionValue } = jest.requireMock( - '@/page-layout/widgets/graph/utils/formatDimensionValue', -) as { formatDimensionValue: jest.Mock }; - -const userTimezone = 'Europe/Paris'; - -describe('formatPrimaryDimensionValues', () => { - it('includes buckets where the primary dimension value is null', () => { - const result = formatPrimaryDimensionValues({ - groupByRawResults: [ - { groupByDimensionValues: [null] }, - { groupByDimensionValues: ['Active'] }, - ], - primaryAxisGroupByField: { - name: 'status', - type: FieldMetadataType.TEXT, - } as any, - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(result).toHaveLength(2); - expect(result[0].rawPrimaryDimensionValue).toBeNull(); - expect(result[0].formattedPrimaryDimensionValue).toBe('formatted-null'); - expect(result[1].formattedPrimaryDimensionValue).toBe('formatted-Active'); - }); - - it('passes granularity and subfield to the formatter', () => { - formatDimensionValue.mockClear(); - - formatPrimaryDimensionValues({ - groupByRawResults: [ - { groupByDimensionValues: ['2024-01-15T00:00:00.000Z'] }, - ], - primaryAxisGroupByField: { - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - } as any, - primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - primaryAxisGroupBySubFieldName: 'createdAt', - userTimezone, - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - - expect(formatDimensionValue).toHaveBeenCalledWith({ - value: '2024-01-15T00:00:00.000Z', - fieldMetadata: expect.objectContaining({ - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }), - dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, - subFieldName: 'createdAt', - userTimezone: 'Europe/Paris', - firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts deleted file mode 100644 index 1a2cd932d1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { generateGroupByQueryVariablesFromBarOrLineChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration'; -import { - FieldMetadataType, - ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; -import { - AggregateOperations, - type BarChartConfiguration, - type LineChartConfiguration, - WidgetConfigurationType, -} from '~/generated/graphql'; - -describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => { - const mockObjectMetadataItem: ObjectMetadataItem = { - id: 'obj-1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [ - { - id: 'field-1', - name: 'stage', - type: FieldMetadataType.TEXT, - }, - { - id: 'field-2', - name: 'owner', - type: FieldMetadataType.RELATION, - }, - { - id: 'field-3', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - { - id: 'field-4', - name: 'name', - type: FieldMetadataType.FULL_NAME, - }, - ], - } as ObjectMetadataItem; - - const buildBarChartConfiguration = ( - overrides: Partial, - ): BarChartConfiguration => - ({ - __typename: 'BarChartConfiguration', - aggregateFieldMetadataId: 'aggregate-field', - aggregateOperation: AggregateOperations.COUNT, - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - ...overrides, - }) as BarChartConfiguration; - - const buildLineChartConfiguration = ( - overrides: Partial, - ): LineChartConfiguration => - ({ - __typename: 'LineChartConfiguration', - aggregateFieldMetadataId: 'aggregate-field', - aggregateOperation: AggregateOperations.COUNT, - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - ...overrides, - }) as LineChartConfiguration; - - describe('Bar Chart Configuration', () => { - describe('Vertical bar configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with two groupBy fields', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - secondaryAxisGroupByFieldMetadataId: 'field-2', - secondaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with composite field', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-4', - primaryAxisGroupBySubFieldName: 'firstName', - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with date field and granularity', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-3', - primaryAxisGroupBySubFieldName: null, - primaryAxisDateGranularity: 'MONTH' as any, - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result).toMatchSnapshot(); - }); - }); - - describe('Horizontal bar configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with secondary axis', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - secondaryAxisGroupByFieldMetadataId: 'field-2', - }), - }); - - expect(result).toMatchSnapshot(); - }); - }); - - it('applies date granularity when primary axis uses a relation date subfield', () => { - const relationField = { - id: 'field-rel', - name: 'company', - type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, - }; - - const objectMetadataItem: ObjectMetadataItem = { - id: 'obj-main', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [relationField], - } as ObjectMetadataItem; - - const targetObjectMetadata: ObjectMetadataItem = { - id: 'obj-company', - nameSingular: 'company', - namePlural: 'companies', - fields: [ - { - id: 'company-created-at', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - ], - } as ObjectMetadataItem; - - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem, - objectMetadataItems: [objectMetadataItem, targetObjectMetadata], - chartConfiguration: buildBarChartConfiguration({ - configurationType: WidgetConfigurationType.BAR_CHART, - primaryAxisGroupByFieldMetadataId: relationField.id, - primaryAxisGroupBySubFieldName: 'createdAt', - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result.groupBy[0]).toEqual({ - company: { - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.DAY, - timeZone: 'Europe/Paris', - }, - }, - }); - }); - - it('applies date granularity for relation date subfield in line charts as well', () => { - const relationField = { - id: 'field-rel', - name: 'company', - type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, - }; - - const objectMetadataItem: ObjectMetadataItem = { - id: 'obj-main', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [relationField], - } as ObjectMetadataItem; - - const targetObjectMetadata: ObjectMetadataItem = { - id: 'obj-company', - nameSingular: 'company', - namePlural: 'companies', - fields: [ - { - id: 'company-created-at', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - ], - } as ObjectMetadataItem; - - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem, - objectMetadataItems: [objectMetadataItem, targetObjectMetadata], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: relationField.id, - primaryAxisGroupBySubFieldName: 'createdAt', - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result.groupBy[0]).toEqual({ - company: { - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.DAY, - timeZone: 'Europe/Paris', - }, - }, - }); - }); - }); - - describe('Line Chart Configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with two groupBy fields (multi-series)', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - secondaryAxisGroupByFieldMetadataId: 'field-2', - secondaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with date field and granularity', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-3', - primaryAxisGroupBySubFieldName: null, - primaryAxisDateGranularity: 'MONTH' as any, - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with composite field', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-4', - primaryAxisGroupBySubFieldName: 'firstName', - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with secondary axis', () => { - const result = - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - configurationType: WidgetConfigurationType.LINE_CHART, - primaryAxisGroupByFieldMetadataId: 'field-1', - secondaryAxisGroupByFieldMetadataId: 'field-2', - }), - }); - - expect(result).toMatchSnapshot(); - }); - }); - - describe('Error handling', () => { - it('should throw error when primary axis field not found (bar chart)', () => { - expect(() => - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildBarChartConfiguration({ - primaryAxisGroupByFieldMetadataId: 'invalid-field', - }), - }), - ).toThrow('Field with id invalid-field not found in object metadata'); - }); - - it('should throw error when primary axis field not found (line chart)', () => { - expect(() => - generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildLineChartConfiguration({ - primaryAxisGroupByFieldMetadataId: 'invalid-field', - }), - }), - ).toThrow('Field with id invalid-field not found in object metadata'); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts deleted file mode 100644 index 47818aa29e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { generateGroupByQueryVariablesFromPieChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { - AggregateOperations, - FieldMetadataType, - GraphOrderBy, - WidgetConfigurationType, - type PieChartConfiguration, -} from '~/generated-metadata/graphql'; - -describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => { - const mockObjectMetadataItem: ObjectMetadataItem = { - id: 'obj-1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [ - { - id: 'field-1', - name: 'stage', - type: FieldMetadataType.TEXT, - }, - { - id: 'field-2', - name: 'owner', - type: FieldMetadataType.RELATION, - }, - { - id: 'field-3', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - { - id: 'field-4', - name: 'name', - type: FieldMetadataType.FULL_NAME, - }, - ], - } as ObjectMetadataItem; - - const buildPieChartConfiguration = ( - overrides: Partial, - ): PieChartConfiguration => - ({ - __typename: 'PieChartConfiguration', - aggregateFieldMetadataId: 'aggregate-field', - aggregateOperation: AggregateOperations.COUNT, - configurationType: WidgetConfigurationType.PIE_CHART, - groupByFieldMetadataId: 'field-1', - ...overrides, - }) as PieChartConfiguration; - - describe('Basic Configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'field-1', - groupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with composite field', () => { - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'field-4', - groupBySubFieldName: 'firstName', - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with date field and granularity', () => { - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'field-3', - groupBySubFieldName: null, - dateGranularity: 'MONTH' as any, - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with limit', () => { - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'field-1', - }), - limit: 10, - }); - - expect(result).toMatchSnapshot(); - expect(result.limit).toBe(10); - }); - - it('should generate variables with orderBy', () => { - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'field-1', - orderBy: GraphOrderBy.VALUE_ASC, - }), - aggregateOperation: 'count', - }); - - expect(result).toMatchSnapshot(); - expect(result.orderBy).toBeDefined(); - }); - }); - - describe('Relation date subfield', () => { - it('applies date granularity when grouping by a relation date subfield', () => { - const relationField = { - id: 'field-rel', - name: 'company', - type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, - }; - - const mainObjectMetadata: ObjectMetadataItem = { - id: 'obj-main', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [relationField], - } as ObjectMetadataItem; - - const targetObjectMetadata: ObjectMetadataItem = { - id: 'obj-company', - nameSingular: 'company', - namePlural: 'companies', - fields: [ - { - id: 'company-created-at', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - ], - } as ObjectMetadataItem; - - const result = generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mainObjectMetadata, - objectMetadataItems: [mainObjectMetadata, targetObjectMetadata], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: relationField.id, - groupBySubFieldName: 'createdAt', - }), - userTimeZone: 'Europe/Paris', - }); - - expect(result.groupBy[0]).toEqual({ - company: { - createdAt: { - granularity: ObjectRecordGroupByDateGranularity.DAY, - timeZone: 'Europe/Paris', - }, - }, - }); - }); - }); - - describe('Error Handling', () => { - it('should throw error when groupBy field not found', () => { - expect(() => - generateGroupByQueryVariablesFromPieChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - objectMetadataItems: [], - chartConfiguration: buildPieChartConfiguration({ - groupByFieldMetadataId: 'invalid-field', - }), - }), - ).toThrow('Field with id invalid-field not found in object metadata'); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupColor.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupColor.test.ts new file mode 100644 index 0000000000..1db6bb9d65 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupColor.test.ts @@ -0,0 +1,110 @@ +import { GRAPH_COLOR_SCALE_MAX } from '@/page-layout/widgets/graph/constants/GraphColorScaleMax'; +import { GRAPH_COLOR_SCALE_MIN } from '@/page-layout/widgets/graph/constants/GraphColorScaleMinIndex'; +import { GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroupColors'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor'; + +const mockColorScheme: GraphColorScheme = { + name: 'test', + solid: '#solid', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], +}; + +describe('generateGroupColor', () => { + describe('single group case', () => { + it('should return solid color when totalGroups is 1', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: 0, + totalGroups: 1, + }); + + expect(result).toBe('#solid'); + }); + + it('should return solid color when totalGroups is 0', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: 0, + totalGroups: 0, + }); + + expect(result).toBe('#solid'); + }); + }); + + describe('multiple groups case', () => { + it('should return a variation color for multiple groups', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: 0, + totalGroups: 3, + }); + + expect(result).toMatch(/^#v\d+$/); + }); + + it('should return different colors for different group indices', () => { + const results = [0, 1, 2].map((groupIndex) => + generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex, + totalGroups: 3, + }), + ); + + expect(results[0]).not.toBe(results[1]); + expect(results[1]).not.toBe(results[2]); + }); + + it('should use variation indices within the defined scale range', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: 0, + totalGroups: 5, + }); + + const variationIndex = mockColorScheme.variations.indexOf(result); + + expect(variationIndex).toBeGreaterThanOrEqual(GRAPH_COLOR_SCALE_MIN - 1); + expect(variationIndex).toBeLessThanOrEqual(GRAPH_COLOR_SCALE_MAX); + }); + }); + + describe('group index wrapping', () => { + it('should wrap group index when exceeding maximum', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: GRAPH_MAXIMUM_NUMBER_OF_GROUP_COLORS + 1, + totalGroups: 10, + }); + + expect(result).toMatch(/^#v\d+$/); + }); + }); + + describe('total groups capping', () => { + it('should cap total groups to maximum number of colors', () => { + const result = generateGroupColor({ + colorScheme: mockColorScheme, + groupIndex: 0, + totalGroups: 100, + }); + + expect(result).toMatch(/^#v\d+$/); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts new file mode 100644 index 0000000000..ccce05a16a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorScheme.test.ts @@ -0,0 +1,153 @@ +import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; +import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme'; + +describe('getColorScheme', () => { + const mockRegistry: GraphColorRegistry = { + blue: { + name: 'blue', + solid: '#solidBlue', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }, + green: { + name: 'green', + solid: '#solidGreen', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }, + red: { + name: 'red', + solid: '#solidRed', + variations: [ + '#v0', + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + ], + }, + } as unknown as GraphColorRegistry; + + describe('with valid color name', () => { + it('should return the color scheme for a valid color name', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: 'blue', + }); + + expect(result).toEqual(mockRegistry.blue); + }); + + it('should normalize uppercase color names to lowercase', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: 'BLUE' as 'blue', + }); + + expect(result).toEqual(mockRegistry.blue); + }); + + it('should normalize mixed case color names', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: 'Blue' as 'blue', + }); + + expect(result).toEqual(mockRegistry.blue); + }); + }); + + describe('with invalid or missing color name', () => { + it('should return color scheme by fallback index when color name is undefined', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: undefined, + fallbackIndex: 0, + }); + + expect(result.name).toBeDefined(); + }); + + it('should return color scheme by fallback index when color name is not in registry', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: 'invalidColor' as 'blue', + fallbackIndex: 1, + }); + + expect(result.name).toBeDefined(); + }); + + it('should use fallback index 0 when not provided', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: undefined, + }); + + expect(result.name).toBeDefined(); + }); + }); + + describe('with totalGroups parameter', () => { + it('should generate a group color when totalGroups is provided', () => { + const result = getColorScheme({ + registry: mockRegistry, + colorName: 'blue', + fallbackIndex: 0, + totalGroups: 5, + }); + + expect(result.name).toBe('blue'); + expect(result.variations).toEqual(mockRegistry.blue.variations); + }); + + it('should use fallbackIndex for group color generation', () => { + const result1 = getColorScheme({ + registry: mockRegistry, + colorName: 'green', + fallbackIndex: 0, + totalGroups: 3, + }); + + const result2 = getColorScheme({ + registry: mockRegistry, + colorName: 'green', + fallbackIndex: 2, + totalGroups: 3, + }); + + expect(result1.solid).not.toBe(result2.solid); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorSchemeByIndex.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorSchemeByIndex.test.ts new file mode 100644 index 0000000000..dcf3bd144d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getColorSchemeByIndex.test.ts @@ -0,0 +1,78 @@ +import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; +import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex'; + +const createMockColorScheme = (name: string): GraphColorScheme => ({ + name, + solid: `#${name}`, + variations: [ + '#v1', + '#v2', + '#v3', + '#v4', + '#v5', + '#v6', + '#v7', + '#v8', + '#v9', + '#v10', + '#v11', + '#v12', + ], +}); + +describe('getColorSchemeByIndex', () => { + const mockRegistry: GraphColorRegistry = { + green: createMockColorScheme('green'), + blue: createMockColorScheme('blue'), + red: createMockColorScheme('red'), + }; + + it('should return first color scheme for index 0', () => { + const result = getColorSchemeByIndex(mockRegistry, 0); + + expect(result).toEqual(mockRegistry.green); + }); + + it('should return second color scheme for index 1', () => { + const result = getColorSchemeByIndex(mockRegistry, 1); + + expect(result).toEqual(mockRegistry.blue); + }); + + it('should return third color scheme for index 2', () => { + const result = getColorSchemeByIndex(mockRegistry, 2); + + expect(result).toEqual(mockRegistry.red); + }); + + it('should wrap around when index exceeds registry length', () => { + const result = getColorSchemeByIndex(mockRegistry, 3); + + expect(result).toEqual(mockRegistry.green); + }); + + it('should handle large indices by wrapping', () => { + const result = getColorSchemeByIndex(mockRegistry, 10); + + expect(result).toEqual(mockRegistry.blue); + }); + + describe('edge cases', () => { + it('should handle single-item registry', () => { + const singleRegistry: GraphColorRegistry = { + single: createMockColorScheme('single'), + }; + + expect(getColorSchemeByIndex(singleRegistry, 0)).toEqual( + singleRegistry.single, + ); + expect(getColorSchemeByIndex(singleRegistry, 1)).toEqual( + singleRegistry.single, + ); + expect(getColorSchemeByIndex(singleRegistry, 5)).toEqual( + singleRegistry.single, + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts deleted file mode 100644 index ba253b96c9..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; - -describe('getFieldKey', () => { - it('should return field name for non-composite field', () => { - const field = { - name: 'companyName', - type: FieldMetadataType.TEXT, - } as FieldMetadataItem; - - expect(getFieldKey({ field })).toBe('companyName'); - }); - - it('should return composite key for composite field with subFieldName', () => { - const field = { - name: 'address', - type: FieldMetadataType.ADDRESS, - } as FieldMetadataItem; - - expect(getFieldKey({ field, subFieldName: 'city' })).toBe('address.city'); - }); - - it('should return field name when subFieldName is null', () => { - const field = { - name: 'address', - type: FieldMetadataType.ADDRESS, - } as FieldMetadataItem; - - expect(getFieldKey({ field, subFieldName: null })).toBe('address'); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getRelationFieldOrderBy.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getRelationFieldOrderBy.test.ts deleted file mode 100644 index b013cadec1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getRelationFieldOrderBy.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { getRelationFieldOrderBy } from '@/page-layout/widgets/graph/utils/getRelationFieldOrderBy'; -import { - FieldMetadataType, - ObjectRecordGroupByDateGranularity, - OrderByDirection, -} from 'twenty-shared/types'; - -describe('getRelationFieldOrderBy', () => { - const relationField = { - name: 'company', - type: FieldMetadataType.RELATION, - } as any; - - it('returns relation id ordering when no subfield provided', () => { - const orderBy = getRelationFieldOrderBy( - relationField, - null, - OrderByDirection.AscNullsLast, - ); - - expect(orderBy).toEqual({ companyId: OrderByDirection.AscNullsLast }); - }); - - it('adds granularity when nested relation field is a date field', () => { - const orderBy = getRelationFieldOrderBy( - relationField, - 'createdAt', - OrderByDirection.DescNullsLast, - undefined, - true, - ); - - expect(orderBy).toEqual({ - company: { - createdAt: { - orderBy: OrderByDirection.DescNullsLast, - granularity: ObjectRecordGroupByDateGranularity.DAY, - }, - }, - }); - }); - - it('returns nested relation ordering without granularity for non-date subfield', () => { - const orderBy = getRelationFieldOrderBy( - relationField, - 'name', - OrderByDirection.AscNullsLast, - undefined, - false, - ); - - expect(orderBy).toEqual({ - company: { - name: OrderByDirection.AscNullsLast, - }, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts new file mode 100644 index 0000000000..831e779035 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getTickRotationConfig.test.ts @@ -0,0 +1,99 @@ +import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants'; +import { + getTickRotationConfig, + type TickRotationConfig, +} from '@/page-layout/widgets/graph/utils/getTickRotationConfig'; + +describe('getTickRotationConfig', () => { + const defaultAxisFontSize = COMMON_CHART_CONSTANTS.AXIS_FONT_SIZE; + + describe('when width per tick is large enough for horizontal labels', () => { + it('should return no rotation when width per tick is large', () => { + const result: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 200, + axisFontSize: defaultAxisFontSize, + }); + + expect(result.tickRotation).toBe( + COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + ); + expect(result.maxLabelLength).toBeGreaterThan(0); + }); + + it('should calculate maxLabelLength based on widthPerTick and font size', () => { + const widthPerTick = 100; + const result: TickRotationConfig = getTickRotationConfig({ + widthPerTick, + axisFontSize: defaultAxisFontSize, + }); + + const expectedCharacterWidth = + defaultAxisFontSize * + COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO; + const expectedMaxLength = Math.floor( + widthPerTick / expectedCharacterWidth, + ); + + expect(result.tickRotation).toBe( + COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE, + ); + expect(result.maxLabelLength).toBe(expectedMaxLength); + }); + }); + + describe('when width per tick is too small for horizontal labels', () => { + it('should return rotated configuration when width is small', () => { + const result: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 10, + axisFontSize: defaultAxisFontSize, + }); + + expect(result.tickRotation).toBe( + COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE, + ); + expect(result.maxLabelLength).toBeGreaterThan(0); + }); + + it('should return at least 1 for maxLabelLength', () => { + const result: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 1, + axisFontSize: defaultAxisFontSize, + }); + + expect(result.tickRotation).toBe( + COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE, + ); + expect(result.maxLabelLength).toBeGreaterThanOrEqual(1); + }); + }); + + describe('edge cases', () => { + it('should handle zero width per tick', () => { + const result: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 0, + axisFontSize: defaultAxisFontSize, + }); + + expect(result.tickRotation).toBe( + COMMON_CHART_CONSTANTS.TICK_ROTATION_ANGLE, + ); + expect(result.maxLabelLength).toBeGreaterThanOrEqual(1); + }); + + it('should handle different font sizes', () => { + const smallFontResult: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 50, + axisFontSize: 8, + }); + + const largeFontResult: TickRotationConfig = getTickRotationConfig({ + widthPerTick: 50, + axisFontSize: 16, + }); + + expect(smallFontResult.maxLabelLength).toBeGreaterThan( + largeFontResult.maxLabelLength, + ); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/graphFormatters.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/graphFormatters.test.ts new file mode 100644 index 0000000000..087335ab34 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/graphFormatters.test.ts @@ -0,0 +1,194 @@ +import { formatGraphValue } from '@/page-layout/widgets/graph/utils/graphFormatters'; + +describe('formatGraphValue', () => { + describe('number display type (default)', () => { + it('should format number without options', () => { + const result = formatGraphValue(1234.56); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + }); + + it('should format positive numbers', () => { + const result = formatGraphValue(100, { displayType: 'number' }); + + expect(result).toContain('100'); + }); + + it('should format negative numbers with sign', () => { + const result = formatGraphValue(-100, { displayType: 'number' }); + + expect(result).toContain('-'); + expect(result).toContain('100'); + }); + + it('should apply prefix and suffix', () => { + const result = formatGraphValue(100, { + displayType: 'number', + prefix: 'Value: ', + suffix: ' units', + }); + + expect(result).toContain('Value: '); + expect(result).toContain(' units'); + }); + }); + + describe('percentage display type', () => { + it('should convert decimal to percentage', () => { + const result = formatGraphValue(0.5, { displayType: 'percentage' }); + + expect(result).toContain('50'); + expect(result).toContain('%'); + }); + + it('should handle 100% correctly', () => { + const result = formatGraphValue(1, { displayType: 'percentage' }); + + expect(result).toContain('100'); + expect(result).toContain('%'); + }); + + it('should handle small percentages', () => { + const result = formatGraphValue(0.01, { displayType: 'percentage' }); + + expect(result).toContain('1'); + expect(result).toContain('%'); + }); + + it('should handle negative percentages', () => { + const result = formatGraphValue(-0.25, { displayType: 'percentage' }); + + expect(result).toContain('%'); + }); + }); + + describe('shortNumber display type', () => { + it('should format large numbers with K suffix', () => { + const result = formatGraphValue(1500, { displayType: 'shortNumber' }); + + expect(result).toBeDefined(); + }); + + it('should format very large numbers with M suffix', () => { + const result = formatGraphValue(1500000, { displayType: 'shortNumber' }); + + expect(result).toBeDefined(); + }); + + it('should apply prefix to short numbers', () => { + const result = formatGraphValue(1000, { + displayType: 'shortNumber', + prefix: '$', + }); + + expect(result).toContain('$'); + }); + + it('should handle negative short numbers', () => { + const result = formatGraphValue(-2500, { displayType: 'shortNumber' }); + + expect(result).toContain('-'); + }); + }); + + describe('currency display type', () => { + it('should use default $ prefix for currency', () => { + const result = formatGraphValue(100, { displayType: 'currency' }); + + expect(result).toContain('$'); + expect(result).toContain('100'); + }); + + it('should use custom prefix for currency', () => { + const result = formatGraphValue(100, { + displayType: 'currency', + prefix: '€', + }); + + expect(result).toContain('€'); + expect(result).toContain('100'); + }); + + it('should handle negative currency values', () => { + const result = formatGraphValue(-500, { displayType: 'currency' }); + + expect(result).toContain('-'); + expect(result).toContain('$'); + expect(result).toContain('500'); + }); + + it('should apply suffix to currency', () => { + const result = formatGraphValue(100, { + displayType: 'currency', + suffix: ' USD', + }); + + expect(result).toContain(' USD'); + }); + }); + + describe('custom display type', () => { + it('should use custom formatter when provided', () => { + const customFormatter = (value: number) => `Custom: ${value * 2}`; + const result = formatGraphValue(50, { + displayType: 'custom', + customFormatter, + }); + + expect(result).toBe('Custom: 100'); + }); + + it('should ignore other options when using custom formatter', () => { + const customFormatter = (value: number) => `${value}!`; + const result = formatGraphValue(42, { + displayType: 'custom', + customFormatter, + prefix: 'ignored', + suffix: 'ignored', + }); + + expect(result).toBe('42!'); + }); + }); + + describe('decimals option', () => { + it('should respect decimals in percentage format', () => { + const result = formatGraphValue(0.333, { + displayType: 'percentage', + decimals: 2, + }); + + expect(result).toContain('%'); + }); + + it('should respect decimals in currency format', () => { + const result = formatGraphValue(99.999, { + displayType: 'currency', + decimals: 2, + }); + + expect(result).toContain('$'); + }); + }); + + describe('edge cases', () => { + it('should handle zero', () => { + const result = formatGraphValue(0, { displayType: 'number' }); + + expect(result).toBeDefined(); + }); + + it('should handle very small numbers', () => { + const result = formatGraphValue(0.001, { displayType: 'number' }); + + expect(result).toBeDefined(); + }); + + it('should handle very large numbers', () => { + const result = formatGraphValue(999999999999, { displayType: 'number' }); + + expect(result).toBeDefined(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isChartConfigurationTwoDimensional.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isChartConfigurationTwoDimensional.test.ts deleted file mode 100644 index ded1e168ad..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isChartConfigurationTwoDimensional.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - type BarChartConfiguration, - type LineChartConfiguration, -} from '~/generated-metadata/graphql'; -import { isChartConfigurationTwoDimensional } from '@/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional'; - -describe('isChartConfigurationTwoDimensional', () => { - describe('BarChartConfiguration', () => { - it('should return true for bar chart with secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - } as BarChartConfiguration); - - expect(result).toBe(true); - }); - - it('should return false for bar chart without secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: null, - } as BarChartConfiguration); - - expect(result).toBe(false); - }); - - it('should return false for bar chart with undefined secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'BarChartConfiguration', - secondaryAxisGroupByFieldMetadataId: undefined, - } as BarChartConfiguration); - - expect(result).toBe(false); - }); - }); - - describe('LineChartConfiguration', () => { - it('should return true for line chart with secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: 'some-field-id', - } as LineChartConfiguration); - - expect(result).toBe(true); - }); - - it('should return false for line chart without secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: null, - } as LineChartConfiguration); - - expect(result).toBe(false); - }); - - it('should return false for line chart with undefined secondary axis', () => { - const result = isChartConfigurationTwoDimensional({ - __typename: 'LineChartConfiguration', - secondaryAxisGroupByFieldMetadataId: undefined, - } as LineChartConfiguration); - - expect(result).toBe(false); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isCyclicalDateGranularity.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isCyclicalDateGranularity.test.ts new file mode 100644 index 0000000000..116c08e144 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isCyclicalDateGranularity.test.ts @@ -0,0 +1,73 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +import { isCyclicalDateGranularity } from '@/page-layout/widgets/graph/utils/isCyclicalDateGranularity'; + +describe('isCyclicalDateGranularity', () => { + it('should return true for DAY_OF_THE_WEEK', () => { + expect( + isCyclicalDateGranularity( + ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK, + ), + ).toBe(true); + }); + + it('should return true for MONTH_OF_THE_YEAR', () => { + expect( + isCyclicalDateGranularity( + ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR, + ), + ).toBe(true); + }); + + it('should return true for QUARTER_OF_THE_YEAR', () => { + expect( + isCyclicalDateGranularity( + ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR, + ), + ).toBe(true); + }); + + it('should return false for DAY', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.DAY), + ).toBe(false); + }); + + it('should return false for WEEK', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.WEEK), + ).toBe(false); + }); + + it('should return false for MONTH', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.MONTH), + ).toBe(false); + }); + + it('should return false for QUARTER', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.QUARTER), + ).toBe(false); + }); + + it('should return false for YEAR', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.YEAR), + ).toBe(false); + }); + + it('should return false for NONE', () => { + expect( + isCyclicalDateGranularity(ObjectRecordGroupByDateGranularity.NONE), + ).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isCyclicalDateGranularity(undefined)).toBe(false); + }); + + it('should return false for null', () => { + expect(isCyclicalDateGranularity(null)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isRelationNestedFieldDateKind.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isRelationNestedFieldDateKind.test.ts deleted file mode 100644 index c9e97c973d..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isRelationNestedFieldDateKind.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { FieldMetadataType } from 'twenty-shared/types'; - -describe('isRelationNestedFieldDateKind', () => { - const companyObject = { - id: 'company-id', - nameSingular: 'company', - namePlural: 'companies', - fields: [ - { id: 'createdAt', name: 'createdAt', type: FieldMetadataType.DATE_TIME }, - { id: 'name', name: 'name', type: FieldMetadataType.TEXT }, - ], - } as any; - - const relationField = { - name: 'company', - type: FieldMetadataType.RELATION, - relation: { targetObjectMetadata: { nameSingular: 'company' } }, - } as any; - - it('returns true for a relation subfield that is a date type', () => { - const result = isRelationNestedFieldDateKind({ - relationField: relationField, - relationNestedFieldName: 'createdAt', - objectMetadataItems: [companyObject], - }); - expect(result).toBe(true); - }); - - it('returns false when the nested subfield is not a date type', () => { - const result = isRelationNestedFieldDateKind({ - relationField: relationField, - relationNestedFieldName: 'name', - objectMetadataItems: [companyObject], - }); - - expect(result).toBe(false); - }); - - it('returns false when subFieldName is missing', () => { - const result = isRelationNestedFieldDateKind({ - relationField: relationField, - relationNestedFieldName: undefined as string | undefined, - objectMetadataItems: [companyObject], - }); - - expect(result).toBe(false); - }); - - it('returns false for non-relation fields', () => { - const nonRelationField = { - name: 'status', - type: FieldMetadataType.TEXT, - } as any; - - const result = isRelationNestedFieldDateKind({ - relationField: nonRelationField, - relationNestedFieldName: 'createdAt', - objectMetadataItems: [companyObject], - }); - - expect(result).toBe(false); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isTimeRangeDateGranularity.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isTimeRangeDateGranularity.test.ts new file mode 100644 index 0000000000..79bdac59a7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/isTimeRangeDateGranularity.test.ts @@ -0,0 +1,73 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +import { isTimeRangeDateGranularity } from '@/page-layout/widgets/graph/utils/isTimeRangeDateGranularity'; + +describe('isTimeRangeDateGranularity', () => { + it('should return true for WEEK', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.WEEK), + ).toBe(true); + }); + + it('should return true for MONTH', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.MONTH), + ).toBe(true); + }); + + it('should return true for QUARTER', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.QUARTER), + ).toBe(true); + }); + + it('should return true for YEAR', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.YEAR), + ).toBe(true); + }); + + it('should return false for DAY', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.DAY), + ).toBe(false); + }); + + it('should return false for DAY_OF_THE_WEEK', () => { + expect( + isTimeRangeDateGranularity( + ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK, + ), + ).toBe(false); + }); + + it('should return false for MONTH_OF_THE_YEAR', () => { + expect( + isTimeRangeDateGranularity( + ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR, + ), + ).toBe(false); + }); + + it('should return false for QUARTER_OF_THE_YEAR', () => { + expect( + isTimeRangeDateGranularity( + ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR, + ), + ).toBe(false); + }); + + it('should return false for NONE', () => { + expect( + isTimeRangeDateGranularity(ObjectRecordGroupByDateGranularity.NONE), + ).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isTimeRangeDateGranularity(undefined)).toBe(false); + }); + + it('should return false for null', () => { + expect(isTimeRangeDateGranularity(null)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseGraphColor.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseGraphColor.test.ts index 778bf00487..43e9c009e2 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseGraphColor.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/parseGraphColor.test.ts @@ -5,13 +5,25 @@ describe('parseGraphColor', () => { expect(parseGraphColor('auto')).toBe('auto'); }); - it('should return valid ThemeColor values', () => { + it('should return valid ThemeColor values for lowercase input', () => { expect(parseGraphColor('green')).toBe('green'); expect(parseGraphColor('blue')).toBe('blue'); expect(parseGraphColor('red')).toBe('red'); expect(parseGraphColor('purple')).toBe('purple'); }); + it('should normalize uppercase colors to lowercase', () => { + expect(parseGraphColor('GREEN')).toBe('green'); + expect(parseGraphColor('BLUE')).toBe('blue'); + expect(parseGraphColor('RED')).toBe('red'); + expect(parseGraphColor('PURPLE')).toBe('purple'); + }); + + it('should normalize mixed case colors to lowercase', () => { + expect(parseGraphColor('Green')).toBe('green'); + expect(parseGraphColor('Blue')).toBe('blue'); + }); + it('should return undefined for null', () => { expect(parseGraphColor(null)).toBeUndefined(); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts deleted file mode 100644 index 595dbff026..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder'; - -describe('sortByManualOrder', () => { - describe('basic sorting', () => { - it('should sort items according to manual order', () => { - const items = [ - { id: 'c', value: 3 }, - { id: 'a', value: 1 }, - { id: 'b', value: 2 }, - ]; - const manualSortOrder = ['a', 'b', 'c']; - - const result = sortByManualOrder({ - items, - manualSortOrder, - getRawValue: (item) => item.id, - }); - - expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c']); - }); - - it('should handle reverse order', () => { - const items = [ - { id: 'a', value: 1 }, - { id: 'b', value: 2 }, - { id: 'c', value: 3 }, - ]; - const manualSortOrder = ['c', 'b', 'a']; - - const result = sortByManualOrder({ - items, - manualSortOrder, - getRawValue: (item) => item.id, - }); - - expect(result.map((item) => item.id)).toEqual(['c', 'b', 'a']); - }); - }); - - describe('empty manual sort order', () => { - it('should return items unchanged when manual order is empty', () => { - const items = [ - { id: 'c', value: 3 }, - { id: 'a', value: 1 }, - { id: 'b', value: 2 }, - ]; - - const result = sortByManualOrder({ - items, - manualSortOrder: [], - getRawValue: (item) => item.id, - }); - - expect(result).toEqual(items); - }); - }); - - describe('items not in manual order', () => { - it('should place items not in manual order at the end', () => { - const items = [ - { id: 'unknown', value: 0 }, - { id: 'b', value: 2 }, - { id: 'a', value: 1 }, - ]; - const manualSortOrder = ['a', 'b']; - - const result = sortByManualOrder({ - items, - manualSortOrder, - getRawValue: (item) => item.id, - }); - - expect(result.map((item) => item.id)).toEqual(['a', 'b', 'unknown']); - }); - - it('should maintain relative order of items not in manual order', () => { - const items = [ - { id: 'unknown1', value: 0 }, - { id: 'a', value: 1 }, - { id: 'unknown2', value: 0 }, - ]; - const manualSortOrder = ['a']; - - const result = sortByManualOrder({ - items, - manualSortOrder, - getRawValue: (item) => item.id, - }); - - expect(result[0].id).toBe('a'); - expect(result[1].id).toBe('unknown1'); - expect(result[2].id).toBe('unknown2'); - }); - }); - - describe('null and undefined values', () => { - it('should handle null raw values', () => { - const items = [ - { id: null as string | null, value: 0 }, - { id: 'a', value: 1 }, - ]; - - const result = sortByManualOrder({ - items, - manualSortOrder: ['a'], - getRawValue: (item) => item.id, - }); - - expect(result[0].id).toBe('a'); - }); - - it('should handle undefined raw values', () => { - const items = [ - { id: undefined as string | undefined, value: 0 }, - { id: 'a', value: 1 }, - ]; - - const result = sortByManualOrder({ - items, - manualSortOrder: ['a'], - getRawValue: (item) => item.id, - }); - - expect(result[0].id).toBe('a'); - }); - }); - - describe('immutability', () => { - it('should not mutate the original array', () => { - const items = [ - { id: 'c', value: 3 }, - { id: 'a', value: 1 }, - { id: 'b', value: 2 }, - ]; - const originalItems = [...items]; - - sortByManualOrder({ - items, - manualSortOrder: ['a', 'b', 'c'], - getRawValue: (item) => item.id, - }); - - expect(items).toEqual(originalItems); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts deleted file mode 100644 index 54514cf03d..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition'; - -describe('sortBySelectOptionPosition', () => { - const mockOptions: FieldMetadataItemOption[] = [ - { id: '1', value: 'NEW', label: 'New', position: 0, color: 'red' }, - { - id: '2', - value: 'IN_PROGRESS', - label: 'In Progress', - position: 1, - color: 'yellow', - }, - { id: '3', value: 'DONE', label: 'Done', position: 2, color: 'green' }, - { - id: '4', - value: 'ARCHIVED', - label: 'Archived', - position: 3, - color: 'gray', - }, - ]; - - const buildFormattedToRawLookup = ( - values: { formatted: string; raw: string }[], - ): Map => { - const lookup = new Map(); - values.forEach(({ formatted, raw }) => { - lookup.set(formatted, raw as RawDimensionValue); - }); - return lookup; - }; - - describe('ASC direction', () => { - it('should sort items by select option position in ascending order', () => { - const items = [ - { id: 'Done', value: 10 }, - { id: 'New', value: 5 }, - { id: 'In Progress', value: 8 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'Done', raw: 'DONE' }, - { formatted: 'New', raw: 'NEW' }, - { formatted: 'In Progress', raw: 'IN_PROGRESS' }, - ]); - - const result = sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'ASC', - }); - - expect(result.map((item) => item.id)).toEqual([ - 'New', - 'In Progress', - 'Done', - ]); - }); - - it('should place items with unknown options at the end when ascending', () => { - const items = [ - { id: 'Unknown', value: 1 }, - { id: 'Done', value: 10 }, - { id: 'New', value: 5 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'Unknown', raw: 'UNKNOWN_VALUE' }, - { formatted: 'Done', raw: 'DONE' }, - { formatted: 'New', raw: 'NEW' }, - ]); - - const result = sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'ASC', - }); - - expect(result.map((item) => item.id)).toEqual(['New', 'Done', 'Unknown']); - }); - }); - - describe('DESC direction', () => { - it('should sort items by select option position in descending order', () => { - const items = [ - { id: 'New', value: 5 }, - { id: 'Done', value: 10 }, - { id: 'In Progress', value: 8 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'New', raw: 'NEW' }, - { formatted: 'Done', raw: 'DONE' }, - { formatted: 'In Progress', raw: 'IN_PROGRESS' }, - ]); - - const result = sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'DESC', - }); - - expect(result.map((item) => item.id)).toEqual([ - 'Done', - 'In Progress', - 'New', - ]); - }); - - it('should place items with unknown options at the beginning when descending', () => { - const items = [ - { id: 'New', value: 5 }, - { id: 'Unknown', value: 1 }, - { id: 'Done', value: 10 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'New', raw: 'NEW' }, - { formatted: 'Unknown', raw: 'UNKNOWN_VALUE' }, - { formatted: 'Done', raw: 'DONE' }, - ]); - - const result = sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'DESC', - }); - - expect(result.map((item) => item.id)).toEqual(['Unknown', 'Done', 'New']); - }); - }); - - describe('edge cases', () => { - it('should handle empty items array', () => { - const formattedToRawLookup = new Map(); - - const result = sortBySelectOptionPosition({ - items: [], - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item: { id: string }) => item.id, - direction: 'ASC', - }); - - expect(result).toEqual([]); - }); - - it('should handle items not in lookup', () => { - const items = [ - { id: 'NotInLookup', value: 5 }, - { id: 'New', value: 10 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'New', raw: 'NEW' }, - ]); - - const result = sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'ASC', - }); - - expect(result.map((item) => item.id)).toEqual(['New', 'NotInLookup']); - }); - - it('should not mutate the original array', () => { - const items = [ - { id: 'Done', value: 10 }, - { id: 'New', value: 5 }, - ]; - - const formattedToRawLookup = buildFormattedToRawLookup([ - { formatted: 'Done', raw: 'DONE' }, - { formatted: 'New', raw: 'NEW' }, - ]); - - const originalItems = [...items]; - - sortBySelectOptionPosition({ - items, - options: mockOptions, - formattedToRawLookup, - getFormattedValue: (item) => item.id, - direction: 'ASC', - }); - - expect(items).toEqual(originalItems); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartDataIfNeeded.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartDataIfNeeded.test.ts deleted file mode 100644 index 7f699b6ab2..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartDataIfNeeded.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded'; -import { GraphOrderBy } from '~/generated/graphql'; - -describe('sortChartDataIfNeeded', () => { - type TestDataPoint = { label: string; value: number }; - - const testData: TestDataPoint[] = [ - { label: 'Beta', value: 30 }, - { label: 'Alpha', value: 10 }, - { label: 'Gamma', value: 20 }, - ]; - - const fieldAscTestData: TestDataPoint[] = [ - { label: 'Alpha', value: 10 }, - { label: 'Beta', value: 30 }, - { label: 'Gamma', value: 20 }, - ]; - - const fieldDescTestData: TestDataPoint[] = [ - { label: 'Gamma', value: 20 }, - { label: 'Beta', value: 30 }, - { label: 'Alpha', value: 10 }, - ]; - - const formattedToRawLookup = new Map([ - ['Alpha', 'ALPHA'], - ['Beta', 'BETA'], - ['Gamma', 'GAMMA'], - ]); - - const getFieldValue = (item: TestDataPoint) => item.label; - const getNumericValue = (item: TestDataPoint) => item.value; - - describe('null or undefined orderBy', () => { - it('should return data unchanged when orderBy is null', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: null, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result).toEqual(testData); - }); - - it('should return data unchanged when orderBy is undefined', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: undefined, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result).toEqual(testData); - }); - }); - - describe('FIELD_ASC sorting', () => { - it('should return data unchanged by field value ascending, since it is already sorted by the backend', () => { - const result = sortChartDataIfNeeded({ - data: fieldAscTestData, - orderBy: GraphOrderBy.FIELD_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result.map((item) => item.label)).toEqual([ - 'Alpha', - 'Beta', - 'Gamma', - ]); - }); - }); - - describe('FIELD_DESC sorting', () => { - it('should return data unchanged by field value descending, since it is already sorted by the backend', () => { - const result = sortChartDataIfNeeded({ - data: fieldDescTestData, - orderBy: GraphOrderBy.FIELD_DESC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result.map((item) => item.label)).toEqual([ - 'Gamma', - 'Beta', - 'Alpha', - ]); - }); - }); - - describe('VALUE_ASC sorting', () => { - it('should sort by numeric value ascending', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.VALUE_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result.map((item) => item.value)).toEqual([10, 20, 30]); - }); - }); - - describe('VALUE_DESC sorting', () => { - it('should sort by numeric value descending', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.VALUE_DESC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result.map((item) => item.value)).toEqual([30, 20, 10]); - }); - }); - - describe('MANUAL sorting', () => { - it('should sort by manual order', () => { - const manualSortOrder = ['GAMMA', 'ALPHA', 'BETA']; - - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.MANUAL, - manualSortOrder, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result.map((item) => item.label)).toEqual([ - 'Gamma', - 'Alpha', - 'Beta', - ]); - }); - - 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); - }); - - it('should return data unchanged when manual order is null', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.MANUAL, - manualSortOrder: null, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(result).toEqual(testData); - }); - }); - - describe('FIELD_POSITION_ASC sorting', () => { - const selectFieldOptions: FieldMetadataItemOption[] = [ - { id: '1', value: 'ALPHA', label: 'Alpha', position: 2, color: 'blue' }, - { id: '2', value: 'BETA', label: 'Beta', position: 0, color: 'red' }, - { id: '3', value: 'GAMMA', label: 'Gamma', position: 1, color: 'green' }, - ]; - - it('should sort by select option position ascending', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.FIELD_POSITION_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - selectFieldOptions, - }); - - expect(result.map((item) => item.label)).toEqual([ - 'Beta', - 'Gamma', - 'Alpha', - ]); - }); - - it('should throw error when select options are not provided', () => { - expect(() => - sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.FIELD_POSITION_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }), - ).toThrow('Select field options are required'); - }); - - it('should throw error when select options are empty', () => { - expect(() => - sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.FIELD_POSITION_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - selectFieldOptions: [], - }), - ).toThrow('Select field options are required'); - }); - }); - - describe('FIELD_POSITION_DESC sorting', () => { - const selectFieldOptions: FieldMetadataItemOption[] = [ - { id: '1', value: 'ALPHA', label: 'Alpha', position: 2, color: 'blue' }, - { id: '2', value: 'BETA', label: 'Beta', position: 0, color: 'red' }, - { id: '3', value: 'GAMMA', label: 'Gamma', position: 1, color: 'green' }, - ]; - - it('should sort by select option position descending', () => { - const result = sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.FIELD_POSITION_DESC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - selectFieldOptions, - }); - - expect(result.map((item) => item.label)).toEqual([ - 'Alpha', - 'Gamma', - 'Beta', - ]); - }); - }); - - describe('immutability', () => { - it('should not mutate the original data array', () => { - const originalData = [...testData]; - - sortChartDataIfNeeded({ - data: testData, - orderBy: GraphOrderBy.FIELD_ASC, - formattedToRawLookup, - getFieldValue, - getNumericValue, - }); - - expect(testData).toEqual(originalData); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts index f1be6f302b..8933dcd683 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts @@ -1,12 +1,26 @@ +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; + import { sortOptionsForManualOrder } from '@/page-layout/widgets/graph/utils/sortOptionsForManualOrder'; +const createOption = ( + value: string, + label?: string, + position?: number, +): FieldMetadataItemOption => ({ + id: `id-${value}`, + value, + label: label ?? value.toUpperCase(), + color: 'green', + position: position ?? 0, +}); + describe('sortOptionsForManualOrder', () => { - describe('without manual sort order', () => { - it('should sort by position when no manual order provided', () => { + describe('when manualSortOrder is not provided', () => { + it('should sort options by position for undefined order', () => { const options = [ - { value: 'c', position: 2 }, - { value: 'a', position: 0 }, - { value: 'b', position: 1 }, + createOption('c', undefined, 3), + createOption('a', undefined, 1), + createOption('b', undefined, 2), ]; const result = sortOptionsForManualOrder(options, undefined); @@ -14,81 +28,152 @@ describe('sortOptionsForManualOrder', () => { expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']); }); - it('should sort by position when manual order is null', () => { + it('should sort options by position for null order', () => { const options = [ - { value: 'c', position: 2 }, - { value: 'a', position: 0 }, - { value: 'b', position: 1 }, + createOption('z', undefined, 3), + createOption('x', undefined, 1), + createOption('y', undefined, 2), ]; const result = sortOptionsForManualOrder(options, null); - expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']); + expect(result.map((option) => option.value)).toEqual(['x', 'y', 'z']); }); - it('should sort by position when manual order is empty', () => { + it('should sort options by position for empty order array', () => { const options = [ - { value: 'c', position: 2 }, - { value: 'a', position: 0 }, - { value: 'b', position: 1 }, + createOption('m', undefined, 3), + createOption('k', undefined, 1), + createOption('l', undefined, 2), ]; const result = sortOptionsForManualOrder(options, []); - expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']); + expect(result.map((option) => option.value)).toEqual(['k', 'l', 'm']); }); - it('should handle null positions as 0', () => { + it('should handle options with same position', () => { const options = [ - { value: 'b', position: 1 }, - { value: 'a', position: null }, + createOption('b', undefined, 1), + createOption('a', undefined, 1), + createOption('c', undefined, 2), ]; const result = sortOptionsForManualOrder(options, undefined); - expect(result.map((option) => option.value)).toEqual(['a', 'b']); + expect(result.map((option) => option.value)).toEqual(['b', 'a', 'c']); + }); + + it('should handle options with undefined position', () => { + const options = [ + createOption('b', undefined, 2), + createOption('a', undefined, undefined), + createOption('c', undefined, 3), + ]; + + const result = sortOptionsForManualOrder(options, undefined); + + expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']); + }); + + it('should not mutate the original options array', () => { + const options = [ + createOption('c', undefined, 3), + createOption('a', undefined, 1), + createOption('b', undefined, 2), + ]; + const originalOrder = options.map((option) => option.value); + + sortOptionsForManualOrder(options, undefined); + + expect(options.map((option) => option.value)).toEqual(originalOrder); }); }); - describe('with manual sort order', () => { - it('should sort according to manual order', () => { - const options = [ - { value: 'a', position: 0 }, - { value: 'b', position: 1 }, - { value: 'c', position: 2 }, - ]; - const manualSortOrder = ['c', 'a', 'b']; + describe('when manualSortOrder is provided', () => { + it('should sort options according to manual order', () => { + const options = [createOption('a'), createOption('b'), createOption('c')]; + const manualOrder = ['c', 'a', 'b']; - const result = sortOptionsForManualOrder(options, manualSortOrder); + const result = sortOptionsForManualOrder(options, manualOrder); expect(result.map((option) => option.value)).toEqual(['c', 'a', 'b']); }); - it('should place options not in manual order at the end', () => { + it('should handle reverse order', () => { + const options = [createOption('a'), createOption('b'), createOption('c')]; + const manualOrder = ['c', 'b', 'a']; + + const result = sortOptionsForManualOrder(options, manualOrder); + + expect(result.map((option) => option.value)).toEqual(['c', 'b', 'a']); + }); + + it('should place items not in manual order at the end', () => { const options = [ - { value: 'a', position: 0 }, - { value: 'b', position: 1 }, - { value: 'c', position: 2 }, + createOption('a'), + createOption('b'), + createOption('c'), + createOption('d'), ]; - const manualSortOrder = ['b']; + const manualOrder = ['c', 'a']; - const result = sortOptionsForManualOrder(options, manualSortOrder); + const result = sortOptionsForManualOrder(options, manualOrder); - expect(result.map((option) => option.value)).toEqual(['b', 'a', 'c']); + expect(result.map((option) => option.value)).toEqual([ + 'c', + 'a', + 'b', + 'd', + ]); + }); + + it('should handle partial manual order correctly', () => { + const options = [createOption('x'), createOption('y'), createOption('z')]; + const manualOrder = ['z']; + + const result = sortOptionsForManualOrder(options, manualOrder); + + expect(result[0].value).toBe('z'); + }); + + it('should not mutate the original options array', () => { + const options = [createOption('a'), createOption('b'), createOption('c')]; + const originalOrder = options.map((option) => option.value); + const manualOrder = ['c', 'b', 'a']; + + sortOptionsForManualOrder(options, manualOrder); + + expect(options.map((option) => option.value)).toEqual(originalOrder); }); }); - describe('immutability', () => { - it('should not mutate the original array', () => { - const options = [ - { value: 'c', position: 2 }, - { value: 'a', position: 0 }, - ]; - const originalOptions = [...options]; + describe('edge cases', () => { + it('should handle empty options array', () => { + const options: FieldMetadataItemOption[] = []; + const manualOrder = ['a', 'b']; - sortOptionsForManualOrder(options, ['a', 'c']); + const result = sortOptionsForManualOrder(options, manualOrder); - expect(options).toEqual(originalOptions); + expect(result).toEqual([]); + }); + + it('should handle single option', () => { + const options = [createOption('a')]; + const manualOrder = ['a']; + + const result = sortOptionsForManualOrder(options, manualOrder); + + expect(result.map((option) => option.value)).toEqual(['a']); + }); + + it('should handle manual order with values not in options', () => { + const options = [createOption('a'), createOption('b')]; + const manualOrder = ['x', 'a', 'y', 'b', 'z']; + + const result = sortOptionsForManualOrder(options, manualOrder); + + expect(result.map((option) => option.value)).toEqual(['a', 'b']); }); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts deleted file mode 100644 index 65dacdaa0c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData'; -import { GraphOrderBy } from '~/generated/graphql'; - -type TestItem = { label: string }; - -const testItems: TestItem[] = [ - { label: 'Beta' }, - { label: 'Alpha' }, - { label: 'Gamma' }, -]; -const getFormattedValue = (item: TestItem) => item.label; - -describe('sortSecondaryAxisData', () => { - it('should sort by FIELD_ASC', () => { - const result = sortSecondaryAxisData({ - items: testItems, - orderBy: GraphOrderBy.FIELD_ASC, - getFormattedValue, - }); - - expect(result.map((i) => i.label)).toEqual(['Alpha', 'Beta', 'Gamma']); - }); - - it('should sort by FIELD_DESC', () => { - const result = sortSecondaryAxisData({ - items: testItems, - orderBy: GraphOrderBy.FIELD_DESC, - getFormattedValue, - }); - - expect(result.map((i) => i.label)).toEqual(['Gamma', 'Beta', 'Alpha']); - }); - - it('should return items unchanged when orderBy is undefined', () => { - const result = sortSecondaryAxisData({ - items: testItems, - orderBy: undefined, - getFormattedValue, - }); - - expect(result).toEqual(testItems); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts deleted file mode 100644 index cf08a31c59..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded'; -import { GraphOrderBy } from '~/generated/graphql'; - -type TestItem = { label: string }; - -const testData: TestItem[] = [ - { label: 'Beta' }, - { label: 'Alpha' }, - { label: 'Gamma' }, -]; - -const fieldAscTestData: TestItem[] = [ - { label: 'Alpha' }, - { label: 'Beta' }, - { label: 'Gamma' }, -]; - -const formattedToRawLookup = new Map([ - ['Alpha', 'ALPHA'], - ['Beta', 'BETA'], - ['Gamma', 'GAMMA'], -]); - -const getFormattedValue = (item: TestItem) => item.label; - -describe('sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded', () => { - it('should return data unchanged by FIELD_ASC, since it is already sorted by the backend', () => { - const result = - sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({ - data: fieldAscTestData, - orderBy: GraphOrderBy.FIELD_ASC, - formattedToRawLookup, - getFormattedValue, - }); - - expect(result.map((i) => i.label)).toEqual(['Alpha', 'Beta', 'Gamma']); - }); - - it('should sort by MANUAL order', () => { - const result = - sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({ - data: testData, - orderBy: GraphOrderBy.MANUAL, - manualSortOrder: ['GAMMA', 'ALPHA', 'BETA'], - formattedToRawLookup, - getFormattedValue, - }); - - expect(result.map((i) => i.label)).toEqual(['Gamma', 'Alpha', 'Beta']); - }); - - it('should return data unchanged when orderBy is undefined', () => { - const result = - sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({ - data: testData, - orderBy: undefined, - formattedToRawLookup, - getFormattedValue, - }); - - expect(result).toEqual(testData); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts index da38ff2250..c1e8f718e5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts @@ -1,3 +1,5 @@ +import { isString } from '@sniptt/guards'; + import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { isDefined } from 'twenty-shared/utils'; @@ -107,10 +109,7 @@ export const areChartConfigurationFieldsValidForQuery = ( ); case 'IframeConfiguration': - return ( - typeof configuration.url === 'string' && - configuration.url.trim().length > 0 - ); + return isString(configuration.url) && configuration.url.trim().length > 0; default: return false; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts deleted file mode 100644 index e0a6ce89dd..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { type FormattedDimensionValue } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; - -export const buildFormattedToRawLookup = ( - formattedValues: FormattedDimensionValue[], -): Map => { - const lookup = new Map(); - - formattedValues.forEach( - ({ formattedPrimaryDimensionValue, rawPrimaryDimensionValue }) => { - if (!lookup.has(formattedPrimaryDimensionValue)) { - lookup.set(formattedPrimaryDimensionValue, rawPrimaryDimensionValue); - } - }, - ); - - return lookup; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts index 40872309d7..dbee33511e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts @@ -2,27 +2,32 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; import { CalendarStartDay, GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE, } from 'twenty-shared/constants'; import { - type FirstDayOfTheWeek, + FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek, + isDefined, + isFieldMetadataDateKind, +} from 'twenty-shared/utils'; + +const GRAPH_DEFAULT_DATE_GRANULARITY = ObjectRecordGroupByDateGranularity.DAY; export type GroupByFieldObject = Record< string, boolean | Record> >; -export type GroupByFieldObjectParams = { +type BuildGroupByFieldObjectParams = { field: FieldMetadataItem; subFieldName?: string | null; dateGranularity?: ObjectRecordGroupByDateGranularity; - firstDayOfTheWeek?: number | null; + firstDayOfTheWeek?: CalendarStartDay | null; isNestedDateField?: boolean; timeZone?: string; }; @@ -34,7 +39,7 @@ export const buildGroupByFieldObject = ({ firstDayOfTheWeek, isNestedDateField, timeZone, -}: GroupByFieldObjectParams): GroupByFieldObject => { +}: BuildGroupByFieldObjectParams): GroupByFieldObject => { const isRelation = isFieldRelation(field) || isFieldMorphRelation(field); const isComposite = isCompositeFieldType(field.type); const isDateField = isFieldMetadataDateKind(field.type); @@ -57,10 +62,10 @@ export const buildGroupByFieldObject = ({ usedDateGranularity, ); - const timeZoneIsNotProvied = !isDefined(timeZone); + const timeZoneIsNotProvided = !isDefined(timeZone); if (shouldHaveTimeZone) { - if (timeZoneIsNotProvied) { + if (timeZoneIsNotProvided) { throw new Error(`Date order by should have a time zone.`); } else { return { @@ -106,6 +111,7 @@ export const buildGroupByFieldObject = ({ `Composite field ${field.name} requires a subfield to be specified`, ); } + return { [field.name]: { [subFieldName]: true, @@ -122,12 +128,12 @@ export const buildGroupByFieldObject = ({ usedDateGranularity, ); - const timeZoneIsNotProvied = !isDefined(timeZone); + const timeZoneIsNotProvided = !isDefined(timeZone); const result: Record = { granularity: usedDateGranularity }; if (shouldHaveTimeZone) { - if (timeZoneIsNotProvied) { + if (timeZoneIsNotProvided) { throw new Error(`Date order by should have a time zone.`); } else { result.timeZone = timeZone; @@ -139,9 +145,11 @@ export const buildGroupByFieldObject = ({ isDefined(firstDayOfTheWeek) && firstDayOfTheWeek !== CalendarStartDay.SYSTEM ) { - const weekStartDay = CalendarStartDay[ - firstDayOfTheWeek - ] as FirstDayOfTheWeek; + const weekStartDay = + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + firstDayOfTheWeek, + FirstDayOfTheWeek.MONDAY, + ); result.weekStartDay = weekStartDay; } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateAngularGradient.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateAngularGradient.ts deleted file mode 100644 index 60e1fd995e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateAngularGradient.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const calculateAngularGradient = (angle: number) => { - const gradientAngle = (angle * Math.PI) / 180 + Math.PI / 2; - - const dx = Math.sin(gradientAngle); - const dy = -Math.cos(gradientAngle); - - return { - x1: `${50 - dx * 50}%`, - y1: `${50 - dy * 50}%`, - x2: `${50 + dx * 50}%`, - y2: `${50 + dy * 50}%`, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph.ts deleted file mode 100644 index c9bcb22613..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords'; -import { type AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; -import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions'; -import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { GRAPH_DEFAULT_AGGREGATE_VALUE } from '@/page-layout/widgets/graph/constants/GraphDefaultAggregateValue'; -import isEmpty from 'lodash.isempty'; -import { isDefined } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const computeAggregateNumericValueForGraph = ({ - data, - objectMetadataItem, - fieldMetadataId, - aggregateOperation, -}: { - data: AggregateRecordsData; - objectMetadataItem: ObjectMetadataItem; - fieldMetadataId?: string | null; - aggregateOperation?: ExtendedAggregateOperations | null; -}): number => { - if (isEmpty(data)) { - throw new Error('Empty aggregate records data'); - } - - const field = objectMetadataItem.fields?.find( - (field) => field.id === fieldMetadataId, - ); - - if (!isDefined(field)) { - throw new Error(`Field with id ${fieldMetadataId} not found`); - } - - if (!isDefined(aggregateOperation)) { - throw new Error(`Missing aggregate operation for field ${field.name}`); - } - - const aggregateValue = data[field.name]?.[aggregateOperation]; - - if ( - COUNT_AGGREGATE_OPERATION_OPTIONS.includes( - aggregateOperation as AggregateOperations, - ) - ) { - return isDefined(aggregateValue) - ? Number(aggregateValue) - : GRAPH_DEFAULT_AGGREGATE_VALUE; - } - - if (!isDefined(aggregateValue)) { - return GRAPH_DEFAULT_AGGREGATE_VALUE; - } - - if ( - PERCENT_AGGREGATE_OPERATION_OPTIONS.includes( - aggregateOperation as AggregateOperations, - ) - ) { - return Number(aggregateValue) * 100; - } - - switch (field.type) { - case FieldMetadataType.CURRENCY: { - // Convert from micros (millionths) to actual currency amount - return Number(aggregateValue) / 1_000_000; - } - - case FieldMetadataType.NUMBER: { - return Number(aggregateValue); - } - - case FieldMetadataType.DATE_TIME: - case FieldMetadataType.DATE: { - return new Date(aggregateValue as string).getTime(); - } - - default: - throw new Error(`Unsupported field type ${field.type}`); - } -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult.ts deleted file mode 100644 index df07cb4330..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { computeAggregateNumericValueForGraph } from '@/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph'; - -type GroupByRawResult = { - groupByDimensionValues: unknown[]; - [key: string]: unknown; -}; - -type ComputeAggregateFromGroupByResultParams = { - rawResult: GroupByRawResult; - aggregateField: FieldMetadataItem; - aggregateOperation: ExtendedAggregateOperations; - aggregateOperationFromRawResult: string; - objectMetadataItem: ObjectMetadataItem; -}; - -export const computeAggregateValueFromGroupByResult = ({ - rawResult, - aggregateField, - aggregateOperation, - aggregateOperationFromRawResult, - objectMetadataItem, -}: ComputeAggregateFromGroupByResultParams): number => { - const transformedData = { - [aggregateField.name]: { - [aggregateOperation]: rawResult[aggregateOperationFromRawResult], - }, - }; - - return computeAggregateNumericValueForGraph({ - data: transformedData, - objectMetadataItem, - fieldMetadataId: aggregateField.id, - aggregateOperation: aggregateOperation, - }); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts deleted file mode 100644 index cabdefc09c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; - -export const createEmptySelectGroup = ( - dimensionValues: RawDimensionValue[], - aggregateKeys: string[], -): GroupByRawResult => { - const newItem: GroupByRawResult = { - groupByDimensionValues: dimensionValues, - }; - - for (const key of aggregateKeys) { - newItem[key] = 0; - } - - return newItem; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts deleted file mode 100644 index 50c9c261eb..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createVirtualElementFromChartCoordinates.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type VirtualElement } from '@floating-ui/react'; - -type ChartCoordinates = { - left: number; - top: number; -}; - -export const createVirtualElementFromChartCoordinates = ( - chartCoordinates: ChartCoordinates, -): VirtualElement => { - const { left, top } = chartCoordinates; - return { - getBoundingClientRect: () => new DOMRect(left, top, 1, 1), - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractBarChartDataConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractBarChartDataConfiguration.ts new file mode 100644 index 0000000000..0682533d42 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractBarChartDataConfiguration.ts @@ -0,0 +1,28 @@ +import { type BarChartConfiguration } from '~/generated/graphql'; + +type BarChartStyleFields = + | 'displayDataLabel' + | 'displayLegend' + | 'axisNameDisplay' + | 'description' + | 'color'; + +export type BarChartDataConfiguration = Omit< + BarChartConfiguration, + BarChartStyleFields +>; + +export const extractBarChartDataConfiguration = ( + configuration: BarChartConfiguration, +): BarChartDataConfiguration => { + const { + displayDataLabel: _displayDataLabel, + displayLegend: _displayLegend, + axisNameDisplay: _axisNameDisplay, + description: _description, + color: _color, + ...dataConfiguration + } = configuration; + + return dataConfiguration; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractLineChartDataConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractLineChartDataConfiguration.ts new file mode 100644 index 0000000000..c6fa563c24 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractLineChartDataConfiguration.ts @@ -0,0 +1,28 @@ +import { type LineChartConfiguration } from '~/generated/graphql'; + +type LineChartStyleFields = + | 'displayDataLabel' + | 'displayLegend' + | 'axisNameDisplay' + | 'description' + | 'color'; + +export type LineChartDataConfiguration = Omit< + LineChartConfiguration, + LineChartStyleFields +>; + +export const extractLineChartDataConfiguration = ( + configuration: LineChartConfiguration, +): LineChartDataConfiguration => { + const { + displayDataLabel: _displayDataLabel, + displayLegend: _displayLegend, + axisNameDisplay: _axisNameDisplay, + description: _description, + color: _color, + ...dataConfiguration + } = configuration; + + return dataConfiguration; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractPieChartDataConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractPieChartDataConfiguration.ts new file mode 100644 index 0000000000..3dcc2a422a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/extractPieChartDataConfiguration.ts @@ -0,0 +1,28 @@ +import { type PieChartConfiguration } from '~/generated/graphql'; + +type PieChartStyleFields = + | 'displayDataLabel' + | 'displayLegend' + | 'showCenterMetric' + | 'description' + | 'color'; + +export type PieChartDataConfiguration = Omit< + PieChartConfiguration, + PieChartStyleFields +>; + +export const extractPieChartDataConfiguration = ( + configuration: PieChartConfiguration, +): PieChartDataConfiguration => { + const { + displayDataLabel: _displayDataLabel, + displayLegend: _displayLegend, + showCenterMetric: _showCenterMetric, + description: _description, + color: _color, + ...dataConfiguration + } = configuration; + + return dataConfiguration; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts deleted file mode 100644 index 3739277327..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { fillSelectGapsInOneDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData'; -import { fillSelectGapsInTwoDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData'; -import { isDefined } from 'twenty-shared/utils'; - -type FillSelectGapsParams = { - data: GroupByRawResult[]; - selectOptions: FieldMetadataItemOption[] | null | undefined; - aggregateKeys: string[]; - hasSecondDimension?: boolean; -}; - -export const fillSelectGapsInChartData = ({ - data, - selectOptions, - aggregateKeys, - hasSecondDimension = false, -}: FillSelectGapsParams): GroupByRawResult[] => { - if ( - !isDefined(selectOptions) || - selectOptions.length === 0 || - data.length === 0 - ) { - return data; - } - - return hasSecondDimension - ? fillSelectGapsInTwoDimensionalChartData({ - data, - selectOptions, - aggregateKeys, - }) - : fillSelectGapsInOneDimensionalChartData({ - data, - selectOptions, - aggregateKeys, - }); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts deleted file mode 100644 index 67881f8c4b..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; -import { isDefined } from 'twenty-shared/utils'; - -type OneDimensionalFillSelectGapsParams = { - data: GroupByRawResult[]; - selectOptions: FieldMetadataItemOption[]; - aggregateKeys: string[]; -}; - -export const fillSelectGapsInOneDimensionalChartData = ({ - data, - selectOptions, - aggregateKeys, -}: OneDimensionalFillSelectGapsParams): GroupByRawResult[] => { - const existingGroupsMap = new Map(); - - 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); - - return isDefined(existingGroup) - ? existingGroup - : createEmptySelectGroup([option.value], aggregateKeys); - }); - - return filledData; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts deleted file mode 100644 index 0135883763..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; -import { isDefined } from 'twenty-shared/utils'; - -type TwoDimensionalFillSelectGapsParams = { - data: GroupByRawResult[]; - selectOptions: FieldMetadataItemOption[]; - aggregateKeys: string[]; -}; - -export const fillSelectGapsInTwoDimensionalChartData = ({ - data, - selectOptions, - aggregateKeys, -}: TwoDimensionalFillSelectGapsParams): GroupByRawResult[] => { - const existingGroupsMap = new Map(); - const uniqueSecondDimensionValues = new Set(); - - for (const item of data) { - const primaryDimensionValue = item.groupByDimensionValues?.[0]; - - if (!isDefined(primaryDimensionValue)) { - continue; - } - - const secondDimensionValue = (item.groupByDimensionValues?.[1] ?? - null) as RawDimensionValue; - uniqueSecondDimensionValues.add(secondDimensionValue); - - const key = `${String(primaryDimensionValue)}_${String(secondDimensionValue)}`; - existingGroupsMap.set(key, item); - } - - const filledData = selectOptions.flatMap((option) => - Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => { - const key = `${option.value}_${String(secondDimensionValue)}`; - const existingGroup = existingGroupsMap.get(key); - - return isDefined(existingGroup) - ? existingGroup - : createEmptySelectGroup( - [option.value, secondDimensionValue], - aggregateKeys, - ); - }), - ); - - return filledData; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts deleted file mode 100644 index 849799f9f3..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; -import { isDefined } from 'twenty-shared/utils'; - -export type GroupByResultFilterOptions = { - rangeMin?: number | null; - rangeMax?: number | null; - omitNullValues?: boolean; -}; - -type FilterGroupByResultsParams = { - rawResults: GroupByRawResult[]; - filterOptions: GroupByResultFilterOptions; - aggregateField: FieldMetadataItem; - aggregateOperation: ExtendedAggregateOperations; - aggregateOperationFromRawResult: string; - objectMetadataItem: ObjectMetadataItem; -}; - -export const filterGroupByResults = ({ - rawResults, - filterOptions, - aggregateField, - aggregateOperation, - aggregateOperationFromRawResult, - objectMetadataItem, -}: FilterGroupByResultsParams): GroupByRawResult[] => { - const { rangeMin, rangeMax, omitNullValues } = filterOptions; - - const hasActiveFilters = - isDefined(rangeMin) || isDefined(rangeMax) || omitNullValues === true; - - if (!hasActiveFilters) { - return rawResults; - } - - return rawResults.filter((result) => { - const aggregateValue = computeAggregateValueFromGroupByResult({ - rawResult: result, - aggregateField, - aggregateOperation, - aggregateOperationFromRawResult, - objectMetadataItem, - }); - - if (omitNullValues === true && !isDefined(aggregateValue)) { - return false; - } - - if (omitNullValues === true && aggregateValue === 0) { - return false; - } - - if (typeof aggregateValue === 'number') { - if (isDefined(rangeMin) && aggregateValue < rangeMin) { - return false; - } - - if (isDefined(rangeMax) && aggregateValue > rangeMax) { - return false; - } - } - - return true; - }); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts deleted file mode 100644 index a6cb83780e..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; -import { type FirstDayOfTheWeek } from 'twenty-shared/types'; -import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql'; - -type FormatPrimaryDimensionValuesParameters = { - groupByRawResults: GroupByRawResult[]; - primaryAxisGroupByField: FieldMetadataItem; - primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity; - primaryAxisGroupBySubFieldName?: string; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -export type FormattedDimensionValue = { - formattedPrimaryDimensionValue: string; - rawPrimaryDimensionValue: RawDimensionValue; -}; - -export const formatPrimaryDimensionValues = ({ - groupByRawResults, - primaryAxisGroupByField, - primaryAxisDateGranularity, - primaryAxisGroupBySubFieldName, - userTimezone, - firstDayOfTheWeek, -}: FormatPrimaryDimensionValuesParameters): FormattedDimensionValue[] => { - return groupByRawResults.reduce( - (accumulator, rawResult) => { - const groupByDimensionValues = rawResult.groupByDimensionValues; - - const rawPrimaryDimensionValue = (groupByDimensionValues?.[0] ?? - null) as RawDimensionValue; - - const formattedPrimaryDimensionValue = formatDimensionValue({ - value: rawPrimaryDimensionValue, - fieldMetadata: primaryAxisGroupByField, - dateGranularity: primaryAxisDateGranularity, - subFieldName: primaryAxisGroupBySubFieldName, - userTimezone, - firstDayOfTheWeek, - }); - - return [ - ...accumulator, - { - formattedPrimaryDimensionValue, - rawPrimaryDimensionValue, - }, - ]; - }, - [], - ); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts deleted file mode 100644 index f590d78aa7..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { GRAPH_DEFAULT_ORDER_BY } from '@/page-layout/widgets/graph/constants/GraphDefaultOrderBy'; -import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy'; -import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { - type AggregateOrderByWithGroupByField, - type ObjectRecordOrderByForCompositeField, - type ObjectRecordOrderByForRelationField, - type ObjectRecordOrderByForScalarField, - type ObjectRecordOrderByWithGroupByDateField, -} from 'twenty-shared/types'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; -import { - type BarChartConfiguration, - type LineChartConfiguration, -} from '~/generated/graphql'; -import { - buildGroupByFieldObject, - type GroupByFieldObject, -} from './buildGroupByFieldObject'; - -export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({ - objectMetadataItem, - objectMetadataItems, - chartConfiguration, - aggregateOperation, - limit, - firstDayOfTheWeek, - userTimeZone, -}: { - objectMetadataItem: ObjectMetadataItem; - objectMetadataItems: ObjectMetadataItem[]; - chartConfiguration: BarChartConfiguration | LineChartConfiguration; - aggregateOperation?: string; - limit?: number; - firstDayOfTheWeek?: number; - userTimeZone?: string; -}) => { - const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId; - - const groupByFieldYId = - chartConfiguration.secondaryAxisGroupByFieldMetadataId; - - const groupBySubFieldNameX = - chartConfiguration.primaryAxisGroupBySubFieldName ?? undefined; - - const groupBySubFieldNameY = - chartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined; - - const groupByFieldX = objectMetadataItem.fields.find( - (field) => field.id === groupByFieldXId, - ); - - const groupByFieldY = isDefined(groupByFieldYId) - ? objectMetadataItem.fields.find((field) => field.id === groupByFieldYId) - : undefined; - - if (!isDefined(groupByFieldX) || !isDefined(groupByFieldXId)) { - throw new Error( - `Field with id ${groupByFieldXId} not found in object metadata`, - ); - } - - const isFieldXDate = isFieldMetadataDateKind(groupByFieldX.type); - - const isFieldXNestedDate = isRelationNestedFieldDateKind({ - relationField: groupByFieldX, - relationNestedFieldName: groupBySubFieldNameX, - objectMetadataItems, - }); - - const shouldApplyDateGranularityX = isFieldXDate || isFieldXNestedDate; - - const groupBy: Array = []; - - groupBy.push( - buildGroupByFieldObject({ - field: groupByFieldX, - subFieldName: groupBySubFieldNameX, - dateGranularity: shouldApplyDateGranularityX - ? (chartConfiguration.primaryAxisDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - firstDayOfTheWeek, - isNestedDateField: isFieldXNestedDate, - timeZone: userTimeZone, - }), - ); - - if (isDefined(groupByFieldY)) { - const isFieldYDate = isFieldMetadataDateKind(groupByFieldY.type); - - const isFieldYNestedDate = isRelationNestedFieldDateKind({ - relationField: groupByFieldY, - relationNestedFieldName: groupBySubFieldNameY, - objectMetadataItems, - }); - - const shouldApplyDateGranularityY = isFieldYDate || isFieldYNestedDate; - - groupBy.push( - buildGroupByFieldObject({ - field: groupByFieldY, - subFieldName: groupBySubFieldNameY, - dateGranularity: shouldApplyDateGranularityY - ? (chartConfiguration.secondaryAxisGroupByDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - firstDayOfTheWeek, - isNestedDateField: isFieldYNestedDate, - timeZone: userTimeZone, - }), - ); - } - - const orderBy: Array< - | AggregateOrderByWithGroupByField - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByForRelationField - > = []; - - const primaryAxisOrderBy = getGroupByOrderBy({ - graphOrderBy: - chartConfiguration.primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY, - groupByField: groupByFieldX, - groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName, - aggregateOperation, - dateGranularity: shouldApplyDateGranularityX - ? (chartConfiguration.primaryAxisDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - }); - - if (isDefined(primaryAxisOrderBy)) { - orderBy.push(primaryAxisOrderBy); - } - - if (isDefined(groupByFieldY)) { - const isFieldYDateForOrderBy = isFieldMetadataDateKind(groupByFieldY.type); - - const isFieldYNestedDateForOrderBy = isRelationNestedFieldDateKind({ - relationField: groupByFieldY, - relationNestedFieldName: groupBySubFieldNameY, - objectMetadataItems, - }); - - const shouldApplyDateGranularityYForOrderBy = - isFieldYDateForOrderBy || isFieldYNestedDateForOrderBy; - - const secondaryAxisOrderBy = getGroupByOrderBy({ - graphOrderBy: - chartConfiguration.secondaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY, - groupByField: groupByFieldY, - groupBySubFieldName: chartConfiguration.secondaryAxisGroupBySubFieldName, - aggregateOperation, - dateGranularity: shouldApplyDateGranularityYForOrderBy - ? (chartConfiguration.secondaryAxisGroupByDateGranularity ?? - GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - }); - - if (isDefined(secondaryAxisOrderBy)) { - orderBy.push(secondaryAxisOrderBy); - } - } - - return { - groupBy, - orderBy, - ...(isDefined(limit) && { limit }), - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts deleted file mode 100644 index e9e873c693..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy'; -import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { - type AggregateOrderByWithGroupByField, - type ObjectRecordOrderByForCompositeField, - type ObjectRecordOrderByForRelationField, - type ObjectRecordOrderByForScalarField, - type ObjectRecordOrderByWithGroupByDateField, -} from 'twenty-shared/types'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; -import { type PieChartConfiguration } from '~/generated/graphql'; -import { - buildGroupByFieldObject, - type GroupByFieldObject, -} from './buildGroupByFieldObject'; - -export const generateGroupByQueryVariablesFromPieChartConfiguration = ({ - objectMetadataItem, - objectMetadataItems, - chartConfiguration, - aggregateOperation, - limit, - firstDayOfTheWeek, - userTimeZone, -}: { - objectMetadataItem: ObjectMetadataItem; - objectMetadataItems: ObjectMetadataItem[]; - chartConfiguration: PieChartConfiguration; - aggregateOperation?: string; - limit?: number; - firstDayOfTheWeek?: number; - userTimeZone?: string; -}) => { - const groupByFieldId = chartConfiguration.groupByFieldMetadataId; - const groupBySubFieldName = - chartConfiguration.groupBySubFieldName ?? undefined; - const dateGranularity = chartConfiguration.dateGranularity ?? undefined; - - const groupByField = objectMetadataItem.fields.find( - (field) => field.id === groupByFieldId, - ); - - if (!isDefined(groupByField) || !isDefined(groupByFieldId)) { - throw new Error( - `Field with id ${groupByFieldId} not found in object metadata`, - ); - } - - const isFieldDate = isFieldMetadataDateKind(groupByField.type); - - const isNestedDate = isRelationNestedFieldDateKind({ - relationField: groupByField, - relationNestedFieldName: groupBySubFieldName, - objectMetadataItems, - }); - - const shouldApplyDateGranularity = isFieldDate || isNestedDate; - - const groupBy: Array = [ - buildGroupByFieldObject({ - field: groupByField, - subFieldName: groupBySubFieldName, - dateGranularity: shouldApplyDateGranularity - ? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - firstDayOfTheWeek, - isNestedDateField: isNestedDate, - timeZone: userTimeZone, - }), - ]; - - const orderBy: Array< - | AggregateOrderByWithGroupByField - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByForRelationField - > = []; - - if (isDefined(chartConfiguration.orderBy)) { - const orderByItem = getGroupByOrderBy({ - graphOrderBy: chartConfiguration.orderBy, - groupByField, - groupBySubFieldName, - aggregateOperation, - dateGranularity: shouldApplyDateGranularity - ? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY) - : undefined, - }); - - if (isDefined(orderByItem)) { - orderBy.push(orderByItem); - } - } - - return { - groupBy, - ...(orderBy.length > 0 && { orderBy }), - ...(isDefined(limit) && { limit }), - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts index 68884084b9..0d5a722ca5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorScheme.ts @@ -1,9 +1,9 @@ -import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor'; -import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex'; -import { isDefined } from 'twenty-shared/utils'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; +import { generateGroupColor } from '@/page-layout/widgets/graph/utils/generateGroupColor'; +import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex'; +import { isDefined } from 'twenty-shared/utils'; export const getColorScheme = ({ registry, @@ -16,18 +16,25 @@ export const getColorScheme = ({ fallbackIndex?: number; totalGroups?: number; }): GraphColorScheme => { - if (!isDefined(colorName) || !isDefined(registry[colorName])) { + const normalizedColorName = isDefined(colorName) + ? (colorName.toLowerCase() as GraphColor) + : undefined; + + if ( + !isDefined(normalizedColorName) || + !isDefined(registry[normalizedColorName]) + ) { return getColorSchemeByIndex(registry, fallbackIndex ?? 0); } if (!isDefined(totalGroups)) { - return registry[colorName]; + return registry[normalizedColorName]; } return { - ...registry[colorName], + ...registry[normalizedColorName], solid: generateGroupColor({ - colorScheme: registry[colorName], + colorScheme: registry[normalizedColorName], groupIndex: fallbackIndex ?? 0, totalGroups, }), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorSchemeByName.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorSchemeByName.ts deleted file mode 100644 index cf8532d87f..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getColorSchemeByName.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry'; -import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme'; - -export const getColorSchemeByName = ( - registry: GraphColorRegistry, - name: string, -): GraphColorScheme | undefined => { - return registry[name]; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldKey.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldKey.ts deleted file mode 100644 index c3e3f674d3..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldKey.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; -import { isDefined } from 'twenty-shared/utils'; -import { type Maybe } from '~/generated/graphql'; - -type GetFieldKeyParams = { - field: FieldMetadataItem; - subFieldName?: Maybe; -}; - -export const getFieldKey = ({ - field, - subFieldName, -}: GetFieldKeyParams): string => { - const isComposite = isCompositeFieldType(field.type); - - return isComposite && isDefined(subFieldName) - ? `${field.name}.${subFieldName}` - : field.name; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts index a53af4b5e8..d6df8975d0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/graphFormatters.ts @@ -1,9 +1,8 @@ -import { isDefined } from 'twenty-shared/utils'; +import { formatToShortNumber, isDefined } from 'twenty-shared/utils'; import { formatNumber as utilFormatNumber, type FormatNumberOptions, } from '~/utils/format/formatNumber'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; export type GraphValueFormatOptions = { displayType?: 'percentage' | 'number' | 'shortNumber' | 'currency' | 'custom'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional.ts deleted file mode 100644 index 2868b2cdb4..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; -import { - type BarChartConfiguration, - type LineChartConfiguration, -} from '~/generated/graphql'; - -export const isChartConfigurationTwoDimensional = ( - configuration: BarChartConfiguration | LineChartConfiguration, -): boolean => { - return isDefined(configuration.secondaryAxisGroupByFieldMetadataId); -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind.ts index 2712ee1a77..1195f2c2f3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind.ts @@ -3,15 +3,17 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +type IsRelationNestedFieldDateKindParams = { + relationField: FieldMetadataItem; + relationNestedFieldName: string | undefined; + objectMetadataItems: ObjectMetadataItem[]; +}; + export const isRelationNestedFieldDateKind = ({ relationField, relationNestedFieldName, objectMetadataItems, -}: { - relationField: FieldMetadataItem; - relationNestedFieldName: string | undefined; - objectMetadataItems: ObjectMetadataItem[]; -}): boolean => { +}: IsRelationNestedFieldDateKindParams): boolean => { if (!isDefined(relationNestedFieldName)) { return false; } @@ -20,29 +22,29 @@ export const isRelationNestedFieldDateKind = ({ return false; } - const targetObjectNameSingular = - relationField.relation?.targetObjectMetadata?.nameSingular; + const targetObjectId = relationField.relation?.targetObjectMetadata?.id; - if (!isDefined(targetObjectNameSingular)) { + if (!isDefined(targetObjectId)) { return false; } - const targetObjectMetadataItem = objectMetadataItems.find( - (item) => item.nameSingular === targetObjectNameSingular, + const targetObjectMetadata = objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === targetObjectId, ); - if (!isDefined(targetObjectMetadataItem)) { + if (!isDefined(targetObjectMetadata)) { return false; } const nestedFieldName = relationNestedFieldName.split('.')[0]; - const nestedField = targetObjectMetadataItem.fields.find( - (f) => f.name === nestedFieldName, + + const nestedFieldMetadata = targetObjectMetadata.fields.find( + (fieldMetadataItem) => fieldMetadataItem.name === nestedFieldName, ); - if (!isDefined(nestedField)) { + if (!isDefined(nestedFieldMetadata)) { return false; } - return isFieldMetadataDateKind(nestedField.type); + return isFieldMetadataDateKind(nestedFieldMetadata.type); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseGraphColor.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseGraphColor.ts index cce8179294..70f18402c3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseGraphColor.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/parseGraphColor.ts @@ -13,8 +13,10 @@ export const parseGraphColor = ( return 'auto'; } - if (MAIN_COLOR_NAMES.includes(value as ThemeColor)) { - return value as GraphColor; + const normalizedValue = value.toLowerCase(); + + if (MAIN_COLOR_NAMES.includes(normalizedValue as ThemeColor)) { + return normalizedValue as GraphColor; } return undefined; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts deleted file mode 100644 index b9ab19f006..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; -import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; -import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; -import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; -import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils'; - -type OneDimensionalChartConfiguration = { - primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null; - primaryAxisGroupBySubFieldName?: string | null; - aggregateOperation: string; -}; - -type ProcessOneDimensionalGroupByResultsParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: OneDimensionalChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -export type ProcessedOneDimensionalDataPoint = { - xValue: string; - rawXValue: RawDimensionValue; - aggregateValue: number; -}; - -export type ProcessOneDimensionalGroupByResultsOutput = { - processedDataPoints: ProcessedOneDimensionalDataPoint[]; - formattedToRawLookup: Map; -}; - -export const processOneDimensionalGroupByResults = ({ - rawResults, - groupByFieldX, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: ProcessOneDimensionalGroupByResultsParams): ProcessOneDimensionalGroupByResultsOutput => { - const formattedValues = formatPrimaryDimensionValues({ - groupByRawResults: rawResults, - primaryAxisGroupByField: groupByFieldX, - primaryAxisDateGranularity: - configuration.primaryAxisDateGranularity ?? undefined, - primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, - userTimezone, - firstDayOfTheWeek, - }); - - const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); - const processedDataPoints: ProcessedOneDimensionalDataPoint[] = []; - - for (const result of rawResults) { - const dimensionValues = result.groupByDimensionValues; - - if (!isDefined(dimensionValues) || dimensionValues.length < 1) { - continue; - } - - const rawXValue = dimensionValues[0] as RawDimensionValue; - - const xValue = formatDimensionValue({ - value: rawXValue, - fieldMetadata: groupByFieldX, - dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, - subFieldName: primaryAxisSubFieldName ?? undefined, - userTimezone, - firstDayOfTheWeek, - }); - - if (isDefined(rawXValue)) { - formattedToRawLookup.set(xValue, rawXValue); - } - - const aggregateValue = computeAggregateValueFromGroupByResult({ - rawResult: result, - aggregateField, - aggregateOperation: - configuration.aggregateOperation as unknown as ExtendedAggregateOperations, - aggregateOperationFromRawResult: aggregateOperation, - objectMetadataItem, - }); - - if (!isDefined(aggregateValue)) { - continue; - } - - processedDataPoints.push({ - xValue, - rawXValue, - aggregateValue, - }); - } - - return { - processedDataPoints, - formattedToRawLookup, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts deleted file mode 100644 index 89c0259df1..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; -import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; -import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; -import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; -import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; -import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils'; - -type TwoDimensionalChartConfiguration = { - primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null; - primaryAxisGroupBySubFieldName?: string | null; - secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null; - secondaryAxisGroupBySubFieldName?: string | null; - aggregateOperation: string; -}; - -type ProcessTwoDimensionalGroupByResultsParams = { - rawResults: GroupByRawResult[]; - groupByFieldX: FieldMetadataItem; - groupByFieldY: FieldMetadataItem; - aggregateField: FieldMetadataItem; - configuration: TwoDimensionalChartConfiguration; - aggregateOperation: string; - objectMetadataItem: ObjectMetadataItem; - primaryAxisSubFieldName?: string | null; - userTimezone: string; - firstDayOfTheWeek: FirstDayOfTheWeek; -}; - -export type ProcessedTwoDimensionalDataPoint = { - xValue: string; - yValue: string; - rawXValue: RawDimensionValue; - rawYValue: RawDimensionValue; - aggregateValue: number; -}; - -export type ProcessTwoDimensionalGroupByResultsOutput = { - processedDataPoints: ProcessedTwoDimensionalDataPoint[]; - formattedToRawLookup: Map; - yFormattedToRawLookup: Map; -}; - -export const processTwoDimensionalGroupByResults = ({ - rawResults, - groupByFieldX, - groupByFieldY, - aggregateField, - configuration, - aggregateOperation, - objectMetadataItem, - primaryAxisSubFieldName, - userTimezone, - firstDayOfTheWeek, -}: ProcessTwoDimensionalGroupByResultsParams): ProcessTwoDimensionalGroupByResultsOutput => { - const formattedValues = formatPrimaryDimensionValues({ - groupByRawResults: rawResults, - primaryAxisGroupByField: groupByFieldX, - primaryAxisDateGranularity: - configuration.primaryAxisDateGranularity ?? undefined, - primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, - userTimezone, - firstDayOfTheWeek, - }); - - const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); - const yFormattedToRawLookup = new Map(); - 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 xValue = formatDimensionValue({ - value: rawXValue, - fieldMetadata: groupByFieldX, - dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, - subFieldName: primaryAxisSubFieldName ?? undefined, - userTimezone, - firstDayOfTheWeek, - }); - - const yValue = formatDimensionValue({ - value: rawYValue, - fieldMetadata: groupByFieldY, - dateGranularity: - configuration.secondaryAxisGroupByDateGranularity ?? undefined, - subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined, - userTimezone, - firstDayOfTheWeek, - }); - - if (isDefined(rawXValue)) { - formattedToRawLookup.set(xValue, rawXValue); - } - - if (isDefined(rawYValue)) { - yFormattedToRawLookup.set(yValue, rawYValue); - } - - const aggregateValue = computeAggregateValueFromGroupByResult({ - rawResult: result, - aggregateField, - aggregateOperation: - configuration.aggregateOperation as unknown as ExtendedAggregateOperations, - aggregateOperationFromRawResult: aggregateOperation, - objectMetadataItem, - }); - - if (!isDefined(aggregateValue)) { - continue; - } - - processedDataPoints.push({ - xValue, - yValue, - rawXValue, - rawYValue, - aggregateValue, - }); - } - - return { - processedDataPoints, - formattedToRawLookup, - yFormattedToRawLookup, - }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts index c09cd1bedd..bb09bb1c90 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts @@ -1,22 +1,34 @@ -import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder'; +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; import { isDefined } from 'twenty-shared/utils'; -type SelectFieldOption = { - value: string; - position?: number | null; -}; - -export const sortOptionsForManualOrder = ( - options: T[], - manualSortOrder?: string[] | null, -): T[] => { +export const sortOptionsForManualOrder = ( + options: FieldMetadataItemOption[], + manualSortOrder: string[] | null | undefined, +): FieldMetadataItemOption[] => { if (!isDefined(manualSortOrder) || manualSortOrder.length === 0) { - return options.toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0)); + return [...options].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); } - return sortByManualOrder({ - items: options.toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0)), - manualSortOrder, - getRawValue: (option) => option.value, + const orderMap = new Map( + manualSortOrder.map((value, index) => [value, index]), + ); + + return [...options].sort((a, b) => { + const indexA = orderMap.get(a.value); + const indexB = orderMap.get(b.value); + + if (!isDefined(indexA) && !isDefined(indexB)) { + return 0; + } + + if (!isDefined(indexA)) { + return 1; + } + + if (!isDefined(indexB)) { + return -1; + } + + return indexA - indexB; }); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded.ts deleted file mode 100644 index aa574591a9..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder'; -import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition'; -import { assertUnreachable, isDefined } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; - -type SortTwoDimensionalChartPrimaryAxisDataParams = { - data: T[]; - orderBy?: - | GraphOrderBy.FIELD_ASC - | GraphOrderBy.FIELD_DESC - | GraphOrderBy.FIELD_POSITION_ASC - | GraphOrderBy.FIELD_POSITION_DESC - | GraphOrderBy.MANUAL; - manualSortOrder?: string[] | null; - formattedToRawLookup: Map; - getFormattedValue: (item: T) => string; - selectFieldOptions?: FieldMetadataItemOption[] | null; -}; - -export const sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded = < - T, ->({ - data, - orderBy, - manualSortOrder, - formattedToRawLookup, - getFormattedValue, - selectFieldOptions, -}: SortTwoDimensionalChartPrimaryAxisDataParams): T[] => { - if (!isDefined(orderBy)) { - return data; - } - - switch (orderBy) { - case GraphOrderBy.MANUAL: { - if (!isDefined(manualSortOrder)) { - return data; - } - - return sortByManualOrder({ - items: data, - manualSortOrder, - getRawValue: (item) => { - const formattedValue = getFormattedValue(item); - const rawValue = formattedToRawLookup.get(formattedValue); - - return isDefined(rawValue) ? String(rawValue) : formattedValue; - }, - }); - } - - case GraphOrderBy.FIELD_ASC: - case GraphOrderBy.FIELD_DESC: - return data; - - case GraphOrderBy.FIELD_POSITION_ASC: - case GraphOrderBy.FIELD_POSITION_DESC: { - if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) { - throw new Error( - 'Select field options are required for field position sorting', - ); - } - - return sortBySelectOptionPosition({ - items: data, - options: selectFieldOptions, - formattedToRawLookup, - getFormattedValue, - direction: orderBy === GraphOrderBy.FIELD_POSITION_ASC ? 'ASC' : 'DESC', - }); - } - - default: - assertUnreachable(orderBy); - } -}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx index c4b61fd10d..8a24acab92 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/CurrencyDisplay.tsx @@ -8,9 +8,8 @@ import { } from '@/object-record/record-field/ui/types/FieldMetadata'; import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, formatToShortNumber } from 'twenty-shared/utils'; import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/formatNumber'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; type CurrencyDisplayProps = { diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto.ts index d42387a9d0..281ad6e530 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto.ts @@ -18,12 +18,11 @@ import { import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { RatioAggregateConfigDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/ratio-aggregate-config.dto'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { PageLayoutWidgetConfigurationBase } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configurationt-base.type'; @ObjectType('AggregateChartConfiguration') @@ -68,7 +67,7 @@ export class AggregateChartConfigurationDTO @Field(() => GraphQLJSON, { nullable: true }) @IsObject() @IsOptional() - filter?: ObjectRecordFilter; + filter?: ChartFilter; @Field(() => String, { nullable: true, defaultValue: 'UTC' }) @IsTimeZone() diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts index bb73d0d55c..b63a753a67 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts @@ -18,8 +18,6 @@ import { import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { AxisNameDisplay } from 'src/engine/metadata-modules/page-layout-widget/enums/axis-name-display.enum'; @@ -28,6 +26,7 @@ import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/e import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum'; import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { PageLayoutWidgetConfigurationBase } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configurationt-base.type'; @ObjectType('BarChartConfiguration') @@ -153,7 +152,7 @@ export class BarChartConfigurationDTO @Field(() => GraphQLJSON, { nullable: true }) @IsObject() @IsOptional() - filter?: ObjectRecordFilter; + filter?: ChartFilter; @Field(() => BarChartGroupMode, { nullable: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto.ts index d565afeb46..1269bf790a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto.ts @@ -16,11 +16,10 @@ import { import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { PageLayoutWidgetConfigurationBase } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configurationt-base.type'; @ObjectType('GaugeChartConfiguration') @@ -60,7 +59,7 @@ export class GaugeChartConfigurationDTO @Field(() => GraphQLJSON, { nullable: true }) @IsObject() @IsOptional() - filter?: ObjectRecordFilter; + filter?: ChartFilter; @Field(() => String, { nullable: true, defaultValue: 'UTC' }) @IsTimeZone() diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts index 5a7bb8bd51..a28eff2695 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts @@ -18,14 +18,13 @@ import { import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { AxisNameDisplay } from 'src/engine/metadata-modules/page-layout-widget/enums/axis-name-display.enum'; import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum'; import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { PageLayoutWidgetConfigurationBase } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configurationt-base.type'; @ObjectType('LineChartConfiguration') @@ -154,7 +153,7 @@ export class LineChartConfigurationDTO @Field(() => GraphQLJSON, { nullable: true }) @IsObject() @IsOptional() - filter?: ObjectRecordFilter; + filter?: ChartFilter; @Field(() => Boolean, { nullable: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts index b5b3017fb7..435488ae8b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts @@ -17,13 +17,12 @@ import { import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; -import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum'; import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { PageLayoutWidgetConfigurationBase } from 'src/engine/metadata-modules/page-layout-widget/types/page-layout-widget-configurationt-base.type'; @ObjectType('PieChartConfiguration') @@ -110,7 +109,7 @@ export class PieChartConfigurationDTO @Field(() => GraphQLJSON, { nullable: true }) @IsObject() @IsOptional() - filter?: ObjectRecordFilter; + filter?: ChartFilter; @Field(() => String, { nullable: true, defaultValue: 'UTC' }) @IsTimeZone() diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-filter.type.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-filter.type.ts new file mode 100644 index 0000000000..0603a95900 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/types/chart-filter.type.ts @@ -0,0 +1,20 @@ +export type ChartRecordFilter = { + id: string; + fieldMetadataId: string; + operand: string; + value?: string | null; + type?: string; + recordFilterGroupId?: string | null; + subFieldName?: string | null; +}; + +export type ChartRecordFilterGroup = { + id: string; + logicalOperator: string; + parentRecordFilterGroupId?: string | null; +}; + +export type ChartFilter = { + recordFilters?: ChartRecordFilter[]; + recordFilterGroups?: ChartRecordFilterGroup[]; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts b/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts new file mode 100644 index 0000000000..da8dee1ee9 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant.ts new file mode 100644 index 0000000000..cf8de5b164 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant.ts @@ -0,0 +1 @@ +export const BAR_CHART_MAXIMUM_NUMBER_OF_BARS = 100; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-groups-per-bar.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-groups-per-bar.constant.ts new file mode 100644 index 0000000000..0cd5c735b9 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-groups-per-bar.constant.ts @@ -0,0 +1 @@ +export const BAR_CHART_MAXIMUM_NUMBER_OF_GROUPS_PER_BAR = 50; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/date-granularities-without-gap-filling.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/date-granularities-without-gap-filling.constant.ts new file mode 100644 index 0000000000..00cbd84a29 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/date-granularities-without-gap-filling.constant.ts @@ -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, +]); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant.ts new file mode 100644 index 0000000000..b36b4bdc78 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant.ts @@ -0,0 +1 @@ +export const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant.ts similarity index 55% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant.ts index 478863f176..2642d2d93a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant.ts @@ -1,6 +1,4 @@ import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -// Default date granularity for grouping date fields in graphs -// TODO: This will be configurable in the graph editor in the future export const GRAPH_DEFAULT_DATE_GRANULARITY = ObjectRecordGroupByDateGranularity.DAY; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-order-by.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-order-by.constant.ts new file mode 100644 index 0000000000..865ba2872e --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/graph-default-order-by.constant.ts @@ -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; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant.ts new file mode 100644 index 0000000000..06f1ef3166 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant.ts @@ -0,0 +1 @@ +export const LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS = 100; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-non-stacked-series.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-non-stacked-series.constant.ts new file mode 100644 index 0000000000..887acb7ee9 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-non-stacked-series.constant.ts @@ -0,0 +1 @@ +export const LINE_CHART_MAXIMUM_NUMBER_OF_NON_STACKED_SERIES = 50; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-stacked-series.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-stacked-series.constant.ts new file mode 100644 index 0000000000..ba21a01d03 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-stacked-series.constant.ts @@ -0,0 +1 @@ +export const LINE_CHART_MAXIMUM_NUMBER_OF_STACKED_SERIES = 50; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant.ts new file mode 100644 index 0000000000..521fcfccaf --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant.ts @@ -0,0 +1 @@ +export const PIE_CHART_MAXIMUM_NUMBER_OF_SLICES = 100; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/supported-date-granularity-for-gap-filling.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/supported-date-granularity-for-gap-filling.type.ts new file mode 100644 index 0000000000..5ed8442793 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/supported-date-granularity-for-gap-filling.type.ts @@ -0,0 +1,8 @@ +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +export type SupportedDateGranularityForGapFilling = + | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.MONTH + | ObjectRecordGroupByDateGranularity.QUARTER + | ObjectRecordGroupByDateGranularity.YEAR + | ObjectRecordGroupByDateGranularity.WEEK; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/bar-chart-data.input.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/bar-chart-data.input.ts new file mode 100644 index 0000000000..5dfb32ff95 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/bar-chart-data.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/line-chart-data.input.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/line-chart-data.input.ts new file mode 100644 index 0000000000..3d64c31d18 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/line-chart-data.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/pie-chart-data.input.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/pie-chart-data.input.ts new file mode 100644 index 0000000000..e54b64e014 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/inputs/pie-chart-data.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-data-output.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-data-output.dto.ts new file mode 100644 index 0000000000..6a1587f38a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-data-output.dto.ts @@ -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[]; + + @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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-series.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-series.dto.ts new file mode 100644 index 0000000000..4b53edf1e8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/bar-chart-series.dto.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('BarChartSeries') +export class BarChartSeriesDTO { + @Field(() => String) + key: string; + + @Field(() => String) + label: string; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-output.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-output.dto.ts new file mode 100644 index 0000000000..71f984edc6 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-output.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-point.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-point.dto.ts new file mode 100644 index 0000000000..c2b609d85a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-data-point.dto.ts @@ -0,0 +1,10 @@ +import { Field, Float, ObjectType } from '@nestjs/graphql'; + +@ObjectType('LineChartDataPoint') +export class LineChartDataPointDTO { + @Field(() => String) + x: string; + + @Field(() => Float) + y: number; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-series.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-series.dto.ts new file mode 100644 index 0000000000..0090f46ede --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/line-chart-series.dto.ts @@ -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[]; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-item.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-item.dto.ts new file mode 100644 index 0000000000..7b3ab6f345 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-item.dto.ts @@ -0,0 +1,10 @@ +import { Field, Float, ObjectType } from '@nestjs/graphql'; + +@ObjectType('PieChartDataItem') +export class PieChartDataItemDTO { + @Field(() => String) + id: string; + + @Field(() => Float) + value: number; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-output.dto.ts b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-output.dto.ts new file mode 100644 index 0000000000..4fb88c8485 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/dtos/outputs/pie-chart-data-output.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts b/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts new file mode 100644 index 0000000000..3cab26a4ff --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts @@ -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 { + 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.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]; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter.ts b/packages/twenty-server/src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..99055ac1e6 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/filters/chart-data-graphql-api-exception.filter.ts @@ -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); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/bar-chart-data.resolver.ts b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/bar-chart-data.resolver.ts new file mode 100644 index 0000000000..b523cc8aa3 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/bar-chart-data.resolver.ts @@ -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 { + const authContext: AuthContext = { + user, + workspace, + workspaceMemberId, + userWorkspaceId, + }; + + return this.barChartDataService.getBarChartData({ + objectMetadataId: input.objectMetadataId, + configuration: input.configuration, + workspaceId: workspace.id, + authContext, + }); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/line-chart-data.resolver.ts b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/line-chart-data.resolver.ts new file mode 100644 index 0000000000..55554f1b45 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/line-chart-data.resolver.ts @@ -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 { + const authContext: AuthContext = { + user, + workspace, + workspaceMemberId, + userWorkspaceId, + }; + + return this.lineChartDataService.getLineChartData({ + objectMetadataId: input.objectMetadataId, + configuration: input.configuration, + workspaceId: workspace.id, + authContext, + }); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/pie-chart-data.resolver.ts b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/pie-chart-data.resolver.ts new file mode 100644 index 0000000000..d93e2fa4ea --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/resolvers/pie-chart-data.resolver.ts @@ -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 { + const authContext: AuthContext = { + user, + workspace, + workspaceMemberId, + userWorkspaceId, + }; + + return this.pieChartDataService.getPieChartData({ + objectMetadataId: input.objectMetadataId, + configuration: input.configuration, + workspaceId: workspace.id, + authContext, + }); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts new file mode 100644 index 0000000000..7693825149 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts new file mode 100644 index 0000000000..7fa3319c4b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts new file mode 100644 index 0000000000..2d114e6a4f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts @@ -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); + }); + + 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'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts new file mode 100644 index 0000000000..e25ed30fab --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts @@ -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 { + 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 = {}; + + 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(); + + for (const point of processedDataPoints) { + allSecondaryValues.add(point.yFormatted); + } + + const dataMap = new Map>(); + + 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[]; + configuration: BarChartConfigurationDTO; + secondaryFormattedToRawLookup: Map; + 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[], + keys: string[], + rangeMin?: number | null, + rangeMax?: number | null, + ): Record[] { + const runningTotals: Record = {}; + + for (const key of keys) { + runningTotals[key] = 0; + } + + const result: Record[] = []; + + 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); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts new file mode 100644 index 0000000000..d41b93af2a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts @@ -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; + flatObjectMetadataMaps: FlatEntityMaps; + objectIdByNameSingular: Record; + 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 { + 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, + }), + })); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts new file mode 100644 index 0000000000..0ec1eb457b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts @@ -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 { + 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 = {}; + + 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(); + const allSeriesIds = new Set(); + + 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>(); + + 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>; + configuration: LineChartConfigurationDTO; + secondaryFormattedToRawLookup: Map; + 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( + 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; + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts new file mode 100644 index 0000000000..119ac44252 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts @@ -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 { + 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 = {}; + + 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), + }; + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/types/field-metadata-option.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/field-metadata-option.type.ts new file mode 100644 index 0000000000..355468a090 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/field-metadata-option.type.ts @@ -0,0 +1,6 @@ +export type FieldMetadataOption = { + value: string; + label: string; + color?: string; + position: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/group-by-raw-result.type.ts similarity index 73% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/types/group-by-raw-result.type.ts index 9858d4e272..1328eee16b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/group-by-raw-result.type.ts @@ -1,4 +1,4 @@ export type GroupByRawResult = { groupByDimensionValues: unknown[]; - [key: string]: unknown; + aggregateValue: number; }; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/types/raw-dimension-value.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/raw-dimension-value.type.ts new file mode 100644 index 0000000000..3d55d183e8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/raw-dimension-value.type.ts @@ -0,0 +1 @@ +export type RawDimensionValue = string | number | boolean | Date | null; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-group-by-field-object.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-group-by-field-object.util.spec.ts new file mode 100644 index 0000000000..65fafca25a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-group-by-field-object.util.spec.ts @@ -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) => + ({ + 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 }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/compare-dimension-values.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/compare-dimension-values.util.spec.ts new file mode 100644 index 0000000000..d55c0f1404 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/compare-dimension-values.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-date-gaps.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-date-gaps.util.spec.ts new file mode 100644 index 0000000000..4a19de82c5 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-date-gaps.util.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-select-gaps.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-select-gaps.util.spec.ts new file mode 100644 index 0000000000..d76c25af2a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/fill-select-gaps.util.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-by-range.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-by-range.util.spec.ts new file mode 100644 index 0000000000..f077796c27 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-by-range.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-two-dimensional-data-by-range.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-two-dimensional-data-by-range.util.spec.ts new file mode 100644 index 0000000000..5780eb6927 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-two-dimensional-data-by-range.util.spec.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts new file mode 100644 index 0000000000..d282e62248 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts @@ -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 => + ({ + 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'); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/generate-date-groups-in-range.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/generate-date-groups-in-range.util.spec.ts new file mode 100644 index 0000000000..52e10a8f75 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/generate-date-groups-in-range.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts new file mode 100644 index 0000000000..2c90b73df8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts @@ -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 => + ({ + 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, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts new file mode 100644 index 0000000000..21844e2b6b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts @@ -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 => + ({ + 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, + }, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts new file mode 100644 index 0000000000..e00d7d9e8c --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts @@ -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 => + ({ + 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, + }, + }, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/is-relation-nested-field-date-kind.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/is-relation-nested-field-date-kind.util.spec.ts new file mode 100644 index 0000000000..02601d8606 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/is-relation-nested-field-date-kind.util.spec.ts @@ -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 => + ({ + id: 'test-field-id', + name: 'testField', + type: FieldMetadataType.TEXT, + universalIdentifier: 'test-universal-id', + ...overrides, + }) as FlatFieldMetadata; + +const createMockObjectMetadata = ( + overrides: Partial, +): 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 = { + byId: { + [companyObjectId]: companyObject, + }, + idByUniversalIdentifier: {}, + universalIdentifiersByApplicationId: {}, + }; + + const flatFieldMetadataMaps: FlatEntityMaps = { + 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); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/map-order-by-to-direction.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/map-order-by-to-direction.util.spec.ts new file mode 100644 index 0000000000..edfedd3549 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/map-order-by-to-direction.util.spec.ts @@ -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, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-manual-order.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-manual-order.util.spec.ts new file mode 100644 index 0000000000..3f04019850 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-manual-order.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-select-option-position.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-select-option-position.util.spec.ts new file mode 100644 index 0000000000..5d32fe136b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-by-select-option-position.util.spec.ts @@ -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'); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-chart-data-if-needed.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-chart-data-if-needed.util.spec.ts new file mode 100644 index 0000000000..88fdfc96b9 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-chart-data-if-needed.util.spec.ts @@ -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([ + ['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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-secondary-axis-data.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-secondary-axis-data.util.spec.ts new file mode 100644 index 0000000000..11b4b947b4 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/sort-secondary-axis-data.util.spec.ts @@ -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); + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts new file mode 100644 index 0000000000..441b9b95e4 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts @@ -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 }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util.ts new file mode 100644 index 0000000000..fee365119e --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util.ts @@ -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)}`; + } +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts new file mode 100644 index 0000000000..8a6f5ddfa4 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts @@ -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> +>; + +type BuildDateGroupByObjectParams = { + dateGranularity?: ObjectRecordGroupByDateGranularity; + firstDayOfTheWeek?: CalendarStartDay | null; + timeZone?: string; +}; + +const buildDateGroupByObject = ({ + dateGranularity, + firstDayOfTheWeek, + timeZone, +}: BuildDateGroupByObjectParams): Record => { + const usedDateGranularity = dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY; + + const shouldHaveTimeZone = + GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes( + usedDateGranularity, + ); + + const result: Record = { + 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 }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..8410d6539e --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/compareDimensionValues.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/compare-dimension-values.util.ts similarity index 68% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/compareDimensionValues.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/compare-dimension-values.util.ts index 315270c8a3..cee6530025 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/compareDimensionValues.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/compare-dimension-values.util.ts @@ -1,24 +1,27 @@ -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { Temporal } from 'temporal-polyfill'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; +import { + FieldMetadataType, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined, isFieldMetadataDateKind, isFieldMetadataNumericKind, } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; + +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, - fieldType?: FieldMetadataType, ): Temporal.PlainDate | null => { - if (!isDefined(rawValue)) return null; + if (!isDefined(rawValue)) { + return null; + } + const stringValue = String(rawValue); - return fieldType === FieldMetadataType.DATE - ? Temporal.PlainDate.from(stringValue) - : Temporal.Instant.from(stringValue) - .toZonedDateTimeISO('UTC') - .toPlainDate(); + + return Temporal.PlainDate.from(stringValue); }; type CompareDimensionValuesParams = { @@ -28,7 +31,8 @@ type CompareDimensionValuesParams = { formattedValueB: string; direction: 'ASC' | 'DESC'; fieldType?: FieldMetadataType; - subFieldName?: CompositeFieldSubFieldName; + subFieldName?: string; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; }; export const compareDimensionValues = ({ @@ -39,14 +43,19 @@ export const compareDimensionValues = ({ direction, fieldType, subFieldName, + dateGranularity, }: CompareDimensionValuesParams): number => { const applyDirection = (comparison: number) => direction === 'ASC' ? comparison : -comparison; if (isDefined(fieldType)) { - if (isFieldMetadataDateKind(fieldType)) { - const dateA = parseDate(rawValueA, fieldType); - const dateB = parseDate(rawValueB, 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)); } @@ -58,6 +67,7 @@ export const compareDimensionValues = ({ return applyDirection(Number(rawValueA) - Number(rawValueB)); } } + return applyDirection(formattedValueA.localeCompare(formattedValueB)); } diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts new file mode 100644 index 0000000000..d5633b1141 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts @@ -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; + 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, + }, + }); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-date-gaps.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-date-gaps.util.ts new file mode 100644 index 0000000000..13237b9354 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-date-gaps.util.ts @@ -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(); + 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(); + const parsedDates: Temporal.PlainDate[] = []; + const uniqueSecondDimensionValues = new Set(); + + 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 }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-select-gaps.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-select-gaps.util.ts new file mode 100644 index 0000000000..e2b3e491fa --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/fill-select-gaps.util.ts @@ -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(); + + 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(); + const uniqueSecondDimensionValues = new Set(); + + 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; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-by-range.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-by-range.util.ts new file mode 100644 index 0000000000..ca8a11d09f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-by-range.util.ts @@ -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; + }); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-line-chart-x-values-by-range.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-line-chart-x-values-by-range.util.ts new file mode 100644 index 0000000000..6bb6d1b556 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-line-chart-x-values-by-range.util.ts @@ -0,0 +1,33 @@ +import { isNumber } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +export const filterLineChartXValuesByRange = ( + xValues: string[], + seriesMap: Map>, + 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; + }); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util.ts new file mode 100644 index 0000000000..8ece5083ee --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-two-dimensional-data-by-range.util.ts @@ -0,0 +1,33 @@ +import { isNumber } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +export const filterTwoDimensionalDataByRange = < + T extends Record, +>( + 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; + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-date-by-granularity.ts similarity index 100% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/format-date-by-granularity.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts similarity index 84% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts index 2e63c93c53..2146c08f03 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts @@ -1,19 +1,23 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity'; import { t } from '@lingui/core/macro'; -import { isNonEmptyString } from '@sniptt/guards'; +import { isNonEmptyString, isNumber } from '@sniptt/guards'; import { FieldMetadataType, type FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; -import { isDefined, parseToPlainDateOrThrow } from 'twenty-shared/utils'; -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; +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: FieldMetadataItem; + fieldMetadata: FlatFieldMetadata; dateGranularity?: ObjectRecordGroupByDateGranularity; subFieldName?: string; userTimezone: string; @@ -38,6 +42,7 @@ const normalizeMultiSelectValue = (value: unknown): unknown[] => { } const content = trimmed.slice(1, -1); + return content ? content.split(',') : []; }; @@ -61,6 +66,7 @@ export const formatDimensionValue = ({ const selectedOption = fieldMetadata.options?.find( (option) => option.value === value, ); + return selectedOption?.label ?? String(value); } @@ -72,6 +78,7 @@ export const formatDimensionValue = ({ const option = fieldMetadata.options?.find( (option) => option.value === value, ); + return option?.label ?? String(value); }) .join(', '); @@ -118,8 +125,10 @@ export const formatDimensionValue = ({ ) { return String(value); } + return parsedDayString; } + return String(value); } @@ -135,10 +144,12 @@ export const formatDimensionValue = ({ return String(value); } - const numericValue = typeof value === 'number' ? value : Number(value); + const numericValue = isNumber(value) ? value : Number(value); + if (isNaN(numericValue)) { return String(value); } + return formatToShortNumber(numericValue); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/generate-date-groups-in-range.util.ts similarity index 73% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/generate-date-groups-in-range.util.ts index d240411064..19f55d92f5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/generate-date-groups-in-range.util.ts @@ -1,5 +1,3 @@ -import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; - import { type Temporal } from 'temporal-polyfill'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { @@ -7,18 +5,16 @@ import { isPlainDateBeforeOrEqual, } from 'twenty-shared/utils'; -type GenerateDateRangeParams = { +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: - | ObjectRecordGroupByDateGranularity.DAY - | ObjectRecordGroupByDateGranularity.WEEK - | ObjectRecordGroupByDateGranularity.MONTH - | ObjectRecordGroupByDateGranularity.QUARTER - | ObjectRecordGroupByDateGranularity.YEAR; + granularity: SupportedDateGranularityForGapFilling; }; -type GenerateDateRangeResult = { +type GenerateDateGroupsInRangeResult = { dates: Temporal.PlainDate[]; wasTruncated: boolean; }; @@ -27,7 +23,7 @@ export const generateDateGroupsInRange = ({ startDate, endDate, granularity, -}: GenerateDateRangeParams): GenerateDateRangeResult => { +}: GenerateDateGroupsInRangeParams): GenerateDateGroupsInRangeResult => { const dates: Temporal.PlainDate[] = []; let iterations = 0; @@ -36,7 +32,7 @@ export const generateDateGroupsInRange = ({ let currentDateCursor = startDate; while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) { - if (iterations >= BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS) { + if (iterations >= BAR_CHART_MAXIMUM_NUMBER_OF_BARS) { wasTruncated = true; break; } diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util.ts new file mode 100644 index 0000000000..d976289f7f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util.ts @@ -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'; + } +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-metadata.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-metadata.util.ts new file mode 100644 index 0000000000..f609e66746 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-metadata.util.ts @@ -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>, +): 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; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldOrderBy.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts similarity index 52% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldOrderBy.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts index fe17fe5972..be8fff88f3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldOrderBy.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts @@ -1,9 +1,3 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; -import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; -import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { getRelationFieldOrderBy } from '@/page-layout/widgets/graph/utils/getRelationFieldOrderBy'; import { type ObjectRecordGroupByDateGranularity, type ObjectRecordOrderByForCompositeField, @@ -14,8 +8,14 @@ import { } 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 = ( - groupByField: FieldMetadataItem, + groupByFieldMetadata: FlatFieldMetadata, groupBySubFieldName: string | null | undefined, dateGranularity: ObjectRecordGroupByDateGranularity | undefined, direction: OrderByDirection, @@ -24,31 +24,32 @@ export const getFieldOrderBy = ( | ObjectRecordOrderByWithGroupByDateField | ObjectRecordOrderByForCompositeField | ObjectRecordOrderByForRelationField => { - if (isCompositeFieldType(groupByField.type)) { + if (isCompositeFieldMetadataType(groupByFieldMetadata.type)) { if (!isDefined(groupBySubFieldName)) { throw new Error( - `Group by subFieldName is required for composite fields (field: ${groupByField.name})`, + `Group by subFieldName is required for composite fields (field: ${groupByFieldMetadata.name})`, ); } + return { - [groupByField.name]: { + [groupByFieldMetadata.name]: { [groupBySubFieldName]: direction, }, }; } - if (isFieldMetadataDateKind(groupByField.type)) { + if (isFieldMetadataDateKind(groupByFieldMetadata.type)) { return { - [groupByField.name]: { + [groupByFieldMetadata.name]: { orderBy: direction, granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, }, }; } - if (isFieldRelation(groupByField) || isFieldMorphRelation(groupByField)) { + if (isMorphOrRelationFlatFieldMetadata(groupByFieldMetadata)) { return getRelationFieldOrderBy( - groupByField, + groupByFieldMetadata, groupBySubFieldName, direction, dateGranularity, @@ -56,6 +57,6 @@ export const getFieldOrderBy = ( } return { - [groupByField.name]: direction, + [groupByFieldMetadata.name]: direction, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts similarity index 52% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts index 70a99cce4a..eee8faa92d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts @@ -1,6 +1,3 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getFieldOrderBy } from '@/page-layout/widgets/graph/utils/getFieldOrderBy'; -import { mapOrderByToDirection } from '@/page-layout/widgets/graph/utils/mapOrderByToDirection'; import { type AggregateOrderByWithGroupByField, type ObjectRecordGroupByDateGranularity, @@ -10,19 +7,27 @@ import { type ObjectRecordOrderByWithGroupByDateField, } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; + +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, - groupByField, + groupByFieldMetadata, groupBySubFieldName, aggregateOperation, + aggregateFieldMetadata, dateGranularity, }: { graphOrderBy: GraphOrderBy; - groupByField: FieldMetadataItem; + groupByFieldMetadata: FlatFieldMetadata; groupBySubFieldName?: string | null; - aggregateOperation?: string; + aggregateOperation?: AggregateOperations; + aggregateFieldMetadata?: FlatFieldMetadata; dateGranularity?: ObjectRecordGroupByDateGranularity; }): | AggregateOrderByWithGroupByField @@ -35,22 +40,30 @@ export const getGroupByOrderBy = ({ case GraphOrderBy.FIELD_ASC: case GraphOrderBy.FIELD_DESC: return getFieldOrderBy( - groupByField, + groupByFieldMetadata, groupBySubFieldName, dateGranularity, mapOrderByToDirection(graphOrderBy), ); case GraphOrderBy.VALUE_ASC: case GraphOrderBy.VALUE_DESC: { - if (!isDefined(aggregateOperation)) { + if ( + !isDefined(aggregateOperation) || + !isDefined(aggregateFieldMetadata) + ) { throw new Error( - `Aggregate operation not found (field: ${groupByField.name})`, + `Aggregate operation or field metadata not found (field: ${groupByFieldMetadata.name})`, ); } + const aggregateFieldKey = buildAggregateFieldKey({ + aggregateOperation, + aggregateFieldMetadata, + }); + return { aggregate: { - [aggregateOperation]: mapOrderByToDirection(graphOrderBy), + [aggregateFieldKey]: mapOrderByToDirection(graphOrderBy), }, }; } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getRelationFieldOrderBy.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts similarity index 71% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getRelationFieldOrderBy.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts index 9cc5f86cf3..f3744ac753 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getRelationFieldOrderBy.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts @@ -1,5 +1,3 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; import { type ObjectRecordGroupByDateGranularity, type ObjectRecordOrderByForRelationField, @@ -8,8 +6,11 @@ import { } 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 = ( - groupByField: FieldMetadataItem, + groupByFieldMetadata: FlatFieldMetadata, groupBySubFieldName: string | null | undefined, direction: OrderByDirection, dateGranularity?: ObjectRecordGroupByDateGranularity, @@ -17,7 +18,7 @@ export const getRelationFieldOrderBy = ( ): ObjectRecordOrderByForScalarField | ObjectRecordOrderByForRelationField => { if (!isDefined(groupBySubFieldName)) { return { - [`${groupByField.name}Id`]: direction, + [`${groupByFieldMetadata.name}Id`]: direction, }; } @@ -25,7 +26,7 @@ export const getRelationFieldOrderBy = ( if (isNestedDateField === true || isDefined(dateGranularity)) { return { - [groupByField.name]: { + [groupByFieldMetadata.name]: { [nestedFieldName]: { orderBy: direction, granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, @@ -36,14 +37,14 @@ export const getRelationFieldOrderBy = ( if (!isDefined(nestedSubFieldName)) { return { - [groupByField.name]: { + [groupByFieldMetadata.name]: { [nestedFieldName]: direction, }, }; } return { - [groupByField.name]: { + [groupByFieldMetadata.name]: { [nestedFieldName]: { [nestedSubFieldName]: direction, }, diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-select-options.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-select-options.util.ts new file mode 100644 index 0000000000..91ac56e4cb --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-select-options.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-cyclical-date-granularity.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-cyclical-date-granularity.util.ts new file mode 100644 index 0000000000..73e9c3411c --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-cyclical-date-granularity.util.ts @@ -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); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util.ts new file mode 100644 index 0000000000..d6c5863ea7 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util.ts @@ -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; + flatFieldMetadataMaps: FlatEntityMaps; +}): 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); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/map-order-by-to-direction.util.ts similarity index 86% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/map-order-by-to-direction.util.ts index e0b4c200da..8cdda7f0c9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/map-order-by-to-direction.util.ts @@ -1,6 +1,7 @@ import { OrderByDirection } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; + +import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum'; export const mapOrderByToDirection = ( orderByEnum: diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts new file mode 100644 index 0000000000..534efc02b3 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts @@ -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; +}; + +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(); + 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, + }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts new file mode 100644 index 0000000000..45a9d800ae --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts @@ -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; + secondaryFormattedToRawLookup: Map; +}; + +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(); + const secondaryFormattedToRawLookup = new Map(); + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-manual-order.util.ts similarity index 95% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-manual-order.util.ts index d1ecbe5a3c..dfc7f6c28b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-manual-order.util.ts @@ -19,7 +19,7 @@ export const sortByManualOrder = ({ manualSortOrder.map((value, index) => [value, index]), ); - return items.toSorted((a, b) => { + return [...items].sort((a, b) => { const rawValueA = getRawValue(a) ?? ''; const rawValueB = getRawValue(b) ?? ''; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util.ts similarity index 81% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util.ts index ee5926a7a2..3b3441cc5c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-by-select-option-position.util.ts @@ -1,10 +1,15 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; 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 = { items: T[]; - options: FieldMetadataItemOption[]; + options: FieldMetadataOption[]; formattedToRawLookup: Map; getFormattedValue: (item: T) => string; direction: 'ASC' | 'DESC'; @@ -23,7 +28,7 @@ export const sortBySelectOptionPosition = ({ optionValueToPosition.set(option.value, option.position); } - return items.toSorted((a, b) => { + return [...items].sort((a, b) => { const formattedA = getFormattedValue(a); const formattedB = getFormattedValue(b); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartDataIfNeeded.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util.ts similarity index 52% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartDataIfNeeded.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util.ts index 62dc169528..7554904f58 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartDataIfNeeded.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util.ts @@ -1,9 +1,19 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder'; -import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition'; +import { + type FieldMetadataType, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { GraphOrderBy } from '~/generated/graphql'; + +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 = { data: T[]; @@ -12,7 +22,10 @@ type SortChartDataParams = { formattedToRawLookup: Map; getFieldValue: (item: T) => string; getNumericValue: (item: T) => number; - selectFieldOptions?: FieldMetadataItemOption[] | null; + selectFieldOptions?: FieldMetadataOption[] | null; + fieldType?: FieldMetadataType; + subFieldName?: string; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; }; export const sortChartDataIfNeeded = ({ @@ -23,6 +36,9 @@ export const sortChartDataIfNeeded = ({ getFieldValue, getNumericValue, selectFieldOptions, + fieldType, + subFieldName, + dateGranularity, }: SortChartDataParams): T[] => { if (!isDefined(orderBy)) { return data; @@ -46,15 +62,29 @@ export const sortChartDataIfNeeded = ({ }); } case GraphOrderBy.VALUE_ASC: - return data.toSorted((a, b) => getNumericValue(a) - getNumericValue(b)); + return [...data].sort((a, b) => getNumericValue(a) - getNumericValue(b)); case GraphOrderBy.VALUE_DESC: - return data.toSorted((a, b) => getNumericValue(b) - getNumericValue(a)); + return [...data].sort((a, b) => getNumericValue(b) - getNumericValue(a)); case GraphOrderBy.FIELD_ASC: case GraphOrderBy.FIELD_DESC: - return data; + 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) { - throw new Error('Select field options are required'); + return data; } return sortBySelectOptionPosition({ @@ -66,7 +96,7 @@ export const sortChartDataIfNeeded = ({ }); case GraphOrderBy.FIELD_POSITION_DESC: if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) { - throw new Error('Select field options are required'); + return data; } return sortBySelectOptionPosition({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util.ts similarity index 60% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts rename to packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util.ts index c1fdc7d523..0b77b45509 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util.ts @@ -1,22 +1,27 @@ -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; -import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; -import { compareDimensionValues } from '@/page-layout/widgets/graph/utils/compareDimensionValues'; -import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder'; -import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition'; -import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; +import { + type FieldMetadataType, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { type FieldMetadataType } from '~/generated-metadata/graphql'; -import { GraphOrderBy } from '~/generated/graphql'; + +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 = { items: T[]; orderBy?: GraphOrderBy | null; manualSortOrder?: string[] | null; formattedToRawLookup?: Map; - selectFieldOptions?: FieldMetadataItemOption[] | null; + selectFieldOptions?: FieldMetadataOption[] | null; getFormattedValue: (item: T) => string; + getNumericValue: (item: T) => number; fieldType?: FieldMetadataType; - subFieldName?: CompositeFieldSubFieldName; + subFieldName?: string; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; }; export const sortSecondaryAxisData = ({ @@ -26,13 +31,19 @@ export const sortSecondaryAxisData = ({ formattedToRawLookup, selectFieldOptions, getFormattedValue, + getNumericValue, fieldType, subFieldName, + dateGranularity, }: SortSecondaryAxisDataParams): T[] => { + if (!isDefined(orderBy)) { + return items; + } + switch (orderBy) { case GraphOrderBy.FIELD_ASC: case GraphOrderBy.FIELD_DESC: - return items.toSorted((a, b) => { + return [...items].sort((a, b) => { const formattedValueA = getFormattedValue(a); const formattedValueB = getFormattedValue(b); @@ -44,9 +55,16 @@ export const sortSecondaryAxisData = ({ 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 ( @@ -54,9 +72,7 @@ export const sortSecondaryAxisData = ({ selectFieldOptions.length === 0 || !isDefined(formattedToRawLookup) ) { - throw new Error( - 'Select field options and formatted to raw lookup are required', - ); + return items; } return sortBySelectOptionPosition({ diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/transform-aggregate-value.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/transform-aggregate-value.util.ts new file mode 100644 index 0000000000..8275271628 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/transform-aggregate-value.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/modules/dashboard/dashboard.module.ts b/packages/twenty-server/src/modules/dashboard/dashboard.module.ts index 703cd93154..7c25794cd7 100644 --- a/packages/twenty-server/src/modules/dashboard/dashboard.module.ts +++ b/packages/twenty-server/src/modules/dashboard/dashboard.module.ts @@ -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, diff --git a/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts b/packages/twenty-shared/src/utils/__tests__/formatToShortNumber.test.ts similarity index 96% rename from packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts rename to packages/twenty-shared/src/utils/__tests__/formatToShortNumber.test.ts index bb0923af1e..e2504cea10 100644 --- a/packages/twenty-front/src/utils/format/__tests__/formatToShortNumber.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/formatToShortNumber.test.ts @@ -1,4 +1,4 @@ -import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; +import { formatToShortNumber } from '../format/formatToShortNumber'; describe('formatToShortNumber', () => { it('formats numbers less than 1000 correctly', () => { diff --git a/packages/twenty-front/src/utils/format/formatToShortNumber.ts b/packages/twenty-shared/src/utils/format/formatToShortNumber.ts similarity index 100% rename from packages/twenty-front/src/utils/format/formatToShortNumber.ts rename to packages/twenty-shared/src/utils/format/formatToShortNumber.ts diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 3c0935c768..d1fe72d777 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -123,6 +123,7 @@ export { relationFilterValueSchemaObject, jsonRelationFilterValueSchema, } from './filter/utils/validation-schemas/jsonRelationFilterValueSchema'; +export { formatToShortNumber } from './format/formatToShortNumber'; export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util'; export { fromArrayToValuesByKeyRecord } from './fromArrayToValuesByKeyRecord.util'; export { getURLSafely } from './getURLSafely';