From 875dc1a6b88f6cfb5f047aa16fde0be629aa6c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 9 Oct 2025 14:54:33 +0200 Subject: [PATCH] Connect the bar chart to the group by resolver (#14885) Closes https://github.com/twentyhq/core-team-issues/issues/1535 Video QA: https://github.com/user-attachments/assets/153fa3f1-08d9-4952-9456-635c602e1f57 https://github.com/user-attachments/assets/d3111afb-4c84-4f57-8a56-5cf666748c86 There are still some formatting and design issues (the labels which are on top of each other and the values which should be formatted) that will be fixed in a future PR --------- Co-authored-by: ehconitin --- .../twenty-front/src/generated/graphql.ts | 9 +- ...onForAggregateOperationDropdownContent.tsx | 4 +- ...oupByFieldSelectionDropdownContentBase.tsx | 4 +- .../utils/computeAggregateValueAndLabel.ts | 6 +- .../PageLayoutInitializationQueryEffect.tsx | 2 +- .../components/PageLayoutRenderer.tsx | 2 +- .../PageLayoutRenderer.stories.tsx | 113 +++++++++++++-- .../fragments/pageLayoutWidgetFragment.ts | 5 + .../page-layout/utils/getGroupByQueryName.ts | 7 + .../graph/components/GraphWidgetRenderer.tsx | 25 +--- .../GraphWidgetBarChart.stories.tsx | 2 +- .../GraphDefaultAggregateValue.constant.ts | 1 + .../constants/GraphDefaultColor.constant.ts | 3 + .../GraphDefaultDateGranularity.constant.ts | 6 + .../GraphMaximumNumberOfGroups.constant.ts | 1 + .../GraphWidgetBarChartRenderer.tsx | 60 ++++++++ .../hooks/useBarChartData.ts | 1 + .../hooks/useGraphBarChartWidgetData.ts | 60 ++++++++ .../graph/hooks/useGraphWidgetGroupByQuery.ts | 74 ++++++++++ .../widgets/graph/types/GraphColor.ts | 3 +- .../widgets/graph/types/GroupByRawResult.ts | 4 + .../formatDateByGranularity.spec.ts.snap | 19 +++ .../generateGroupByQuery.test.ts.snap | 65 +++++++++ ...blesFromBarChartConfiguration.test.ts.snap | 36 +++++ .../__tests__/buildGroupByFieldObject.test.ts | 104 ++++++++++++++ ...mputeAggregateNumericValueForGraph.test.ts | 71 ++++++++++ .../__tests__/formatDateByGranularity.spec.ts | 79 +++++++++++ .../__tests__/generateGroupByQuery.test.ts | 47 +++++++ ...VariablesFromBarChartConfiguration.test.ts | 86 +++++++++++ .../graph/utils/__tests__/getFieldKey.spec.ts | 32 +++++ .../graph/utils/buildGroupByFieldObject.ts | 52 +++++++ .../computeAggregateNumericValueForGraph.ts | 77 ++++++++++ .../computeAggregateValueFromGroupByResult.ts | 38 +++++ .../graph/utils/createGraphColorRegistry.ts | 8 ++ .../graph/utils/formatDateByGranularity.ts | 32 +++++ .../graph/utils/formatDimensionValue.ts | 91 ++++++++++++ .../graph/utils/generateGroupByQuery.ts | 35 +++++ ...QueryVariablesFromBarChartConfiguration.ts | 86 +++++++++++ .../widgets/graph/utils/getFieldKey.ts | 20 +++ .../transformGroupByDataToBarChartData.ts | 133 ++++++++++++++++++ ...formOneDimensionalGroupByToBarChartData.ts | 83 +++++++++++ ...formTwoDimensionalGroupByToBarChartData.ts | 93 ++++++++++++ .../resolvers/types/group-by-field.types.ts | 2 +- ...ult-with-group-by-dimension-values.util.ts | 6 +- .../utils/get-group-by-expression.util.ts | 3 +- .../utils/parse-group-by-args.util.ts | 6 +- .../interfaces/object-record.interface.ts | 13 +- ...te-granularity-gql-input-type.generator.ts | 3 +- .../ObjectRecordGroupByDateGranularity.ts | 10 ++ packages/twenty-shared/src/types/index.ts | 1 + 50 files changed, 1653 insertions(+), 70 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/utils/getGroupByQueryName.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultColor.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts.snap create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeAggregateNumericValueForGraph.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQuery.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldKey.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts create mode 100644 packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 6560ba1fa9d..aa788b415b7 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4432,7 +4432,7 @@ export type SearchQueryVariables = Exact<{ export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: any, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } }; -export type PageLayoutWidgetFragmentFragment = { __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?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }; +export type PageLayoutWidgetFragmentFragment = { __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?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupBySubFieldName?: string | null, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }; export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{ id: Scalars['String']; @@ -4440,7 +4440,7 @@ 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?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }> | null }> | null } }; +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?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'IframeConfiguration', url: string } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupByFieldMetadataIdX: any, groupBySubFieldNameX?: string | null, orderByX: GraphOrderBy, groupByFieldMetadataIdY?: any | null, groupBySubFieldNameY?: string | null, orderByY?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay: AxisNameDisplay, displayDataLabel: boolean, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null } | { __typename?: 'NumberChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, displayDataLabel: boolean, description?: string | null, filter?: any | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: ExtendedAggregateOperations, groupBySubFieldName?: string | null, orderBy: GraphOrderBy, displayDataLabel: boolean, color?: string | null, description?: string | null, filter?: any | null } | null }> | null }> | null } }; export type OnDbEventSubscriptionVariables = Exact<{ input: OnDbEventInput; @@ -4744,8 +4744,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql` aggregateFieldMetadataId aggregateOperation groupByFieldMetadataIdX + groupBySubFieldNameX orderByX groupByFieldMetadataIdY + groupBySubFieldNameY orderByY omitNullValues axisNameDisplay @@ -4761,8 +4763,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql` aggregateFieldMetadataId aggregateOperation groupByFieldMetadataIdX + groupBySubFieldNameX orderByX groupByFieldMetadataIdY + groupBySubFieldNameY orderByY omitNullValues axisNameDisplay @@ -4778,6 +4782,7 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql` groupByFieldMetadataId aggregateFieldMetadataId aggregateOperation + groupBySubFieldName orderBy displayDataLabel color diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionForAggregateOperationDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionForAggregateOperationDropdownContent.tsx index 1c6e3dc9b0d..ba5348e232b 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionForAggregateOperationDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartFieldSelectionForAggregateOperationDropdownContent.tsx @@ -58,8 +58,8 @@ export const ChartFieldSelectionForAggregateOperationDropdownContent = () => { items: sourceObjectMetadataItem?.fields || [], searchQuery, getSearchableValues: (item) => [item.label, item.name], - // TODO: remove this once group by is supported for relation fields - }).filter((field) => !isFieldRelation(field)); + // TODO: remove the relation filter once group by is supported for relation fields + }).filter((field) => !isFieldRelation(field) && !field.isSystem); const { getIcon } = useIcons(); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx index a2fa2a68601..ca40bef19d4 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx @@ -76,8 +76,8 @@ export const ChartGroupByFieldSelectionDropdownContentBase = < items: sourceObjectMetadataItem?.fields || [], searchQuery, getSearchableValues: (item) => [item.label, item.name], - // TODO: remove this once group by is supported for relation fields - }).filter((field) => !isFieldRelation(field)), + // TODO: remove the relation filter once group by is supported for relation fields + }).filter((field) => !isFieldRelation(field) && !field.isSystem), [sourceObjectMetadataItem?.fields, searchQuery], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts index d48a0d2c86a..3337d50f186 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel.ts @@ -31,6 +31,7 @@ export const computeAggregateValueAndLabel = ({ timeZone, localeCatalog, formatNumberFn, + formatShortNumberFn, }: { data: AggregateRecordsData; objectMetadataItem: ObjectMetadataItem; @@ -44,6 +45,7 @@ export const computeAggregateValueAndLabel = ({ value: number, options?: Omit, ) => string; + formatShortNumberFn?: (value: number) => string | number; }) => { const formatNumber = formatNumberFn ?? @@ -95,7 +97,9 @@ export const computeAggregateValueAndLabel = ({ switch (field.type) { case FieldMetadataType.CURRENCY: { value = Number(aggregateValue); - value = formatToShortNumber(value / 1_000_000); + value = isDefined(formatShortNumberFn) + ? formatShortNumberFn(value / 1_000_000) + : formatToShortNumber(value / 1_000_000); break; } diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutInitializationQueryEffect.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutInitializationQueryEffect.tsx index be76b74be95..480e9fec26d 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutInitializationQueryEffect.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutInitializationQueryEffect.tsx @@ -15,7 +15,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; type PageLayoutInitializationQueryEffectProps = { pageLayoutId: string; - onInitialized: (pageLayout: PageLayout) => void; + onInitialized?: (pageLayout: PageLayout) => void; }; export const PageLayoutInitializationQueryEffect = ({ diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx index aa2c1d7c3d9..aed91468264 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutRenderer.tsx @@ -10,7 +10,7 @@ import 'react-resizable/css/styles.css'; type PageLayoutRendererProps = { pageLayoutId: string; - onInitialized: (pageLayout: PageLayout) => void; + onInitialized?: (pageLayout: PageLayout) => void; }; export const PageLayoutRenderer = ({ diff --git a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutRenderer.stories.tsx b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutRenderer.stories.tsx index b30c235027c..8a67788dc71 100644 --- a/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutRenderer.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/__stories__/PageLayoutRenderer.stories.tsx @@ -1,21 +1,33 @@ -import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; +import { + type ApolloClient, + type NormalizedCacheObject, + useApolloClient, +} from '@apollo/client'; +import { type MockedResponse } from '@apollo/client/testing'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, within } from '@storybook/test'; import { MemoryRouter } from 'react-router-dom'; import { FIND_ONE_PAGE_LAYOUT } from '@/dashboards/graphql/queries/findOnePageLayout'; +import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext'; import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer'; -import { RecoilRoot } from 'recoil'; +import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery'; import { GraphOrderBy, GraphType, WidgetType, } from '~/generated-metadata/graphql'; import { + AxisNameDisplay, + type BarChartConfiguration, ExtendedAggregateOperations, PageLayoutType, type PageLayoutWidget, } from '~/generated/graphql'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const mockPersonObjectMetadataItem = getMockObjectMetadataItemOrThrow('person'); const validatePageLayoutContent = async (canvasElement: HTMLElement) => { const canvas = within(canvasElement); @@ -31,7 +43,7 @@ const mixedGraphsPageLayoutMocks = { id: 'mixed-graphs-layout', name: 'Mixed Graph Dashboard', type: PageLayoutType.DASHBOARD, - objectMetadataId: null, + objectMetadataId: mockPersonObjectMetadataItem.id, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', deletedAt: null, @@ -52,7 +64,7 @@ const mixedGraphsPageLayoutMocks = { pageLayoutTabId: 'mixed-tab', type: WidgetType.GRAPH, title: 'Revenue', - objectMetadataId: null, + objectMetadataId: mockPersonObjectMetadataItem.id, gridPosition: { __typename: 'GridPosition', row: 0, @@ -76,7 +88,7 @@ const mixedGraphsPageLayoutMocks = { pageLayoutTabId: 'mixed-tab', type: WidgetType.GRAPH, title: 'Goal Progress', - objectMetadataId: null, + objectMetadataId: mockPersonObjectMetadataItem.id, gridPosition: { __typename: 'GridPosition', row: 0, @@ -101,7 +113,7 @@ const mixedGraphsPageLayoutMocks = { pageLayoutTabId: 'mixed-tab', type: WidgetType.GRAPH, title: 'Revenue Sources', - objectMetadataId: null, + objectMetadataId: mockPersonObjectMetadataItem.id, gridPosition: { __typename: 'GridPosition', row: 0, @@ -127,7 +139,7 @@ const mixedGraphsPageLayoutMocks = { pageLayoutTabId: 'mixed-tab', type: WidgetType.GRAPH, title: 'Quarterly Comparison', - objectMetadataId: null, + objectMetadataId: mockPersonObjectMetadataItem.id, gridPosition: { __typename: 'GridPosition', row: 2, @@ -139,10 +151,16 @@ const mixedGraphsPageLayoutMocks = { __typename: 'BarChartConfiguration', graphType: GraphType.BAR, aggregateOperation: ExtendedAggregateOperations.COUNT, - aggregateFieldMetadataId: 'id', - groupByFieldMetadataIdX: 'createdAt', + aggregateFieldMetadataId: mockPersonObjectMetadataItem.fields.find( + (field) => field.name === 'name', + )?.id, + groupByFieldMetadataIdX: mockPersonObjectMetadataItem.fields.find( + (field) => field.name === 'createdAt', + )?.id, orderByX: GraphOrderBy.FIELD_ASC, - }, + axisNameDisplay: AxisNameDisplay.BOTH, + displayDataLabel: false, + } satisfies BarChartConfiguration, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', deletedAt: null, @@ -152,6 +170,11 @@ const mixedGraphsPageLayoutMocks = { ], }; +const barChartGroupByQuery = generateGroupByQuery({ + objectMetadataItem: mockPersonObjectMetadataItem, + aggregateOperations: ['totalCount'], +}); + const graphqlMocks: MockedResponse[] = [ { request: { @@ -166,19 +189,81 @@ const graphqlMocks: MockedResponse[] = [ }, }, }, + { + request: { + query: barChartGroupByQuery, + variables: { + groupBy: [ + { + createdAt: { + granularity: 'DAY', + }, + }, + ], + }, + }, + result: { + data: { + peopleGroupBy: [ + { + groupByDimensionValues: ['2024-01-15T00:00:00.000Z'], + totalCount: 12, + }, + { + groupByDimensionValues: ['2024-02-15T00:00:00.000Z'], + totalCount: 18, + }, + { + groupByDimensionValues: ['2024-03-15T00:00:00.000Z'], + totalCount: 25, + }, + { + groupByDimensionValues: ['2024-04-15T00:00:00.000Z'], + totalCount: 15, + }, + { + groupByDimensionValues: ['2024-05-15T00:00:00.000Z'], + totalCount: 22, + }, + { + groupByDimensionValues: ['2024-06-15T00:00:00.000Z'], + totalCount: 30, + }, + ], + }, + }, + }, ]; +const CoreClientProviderWrapper = ({ + children, +}: { + children: React.ReactNode; +}) => { + const apolloClient = useApolloClient() as ApolloClient; + + return ( + + {children} + + ); +}; + +const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: graphqlMocks, +}); + const meta: Meta = { title: 'Modules/PageLayout/PageLayoutRenderer', component: PageLayoutRenderer, decorators: [ (Story) => ( - - + + - - + + ), ], diff --git a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts index f418af22738..29afebfc4ee 100644 --- a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts +++ b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts @@ -21,8 +21,10 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql` aggregateFieldMetadataId aggregateOperation groupByFieldMetadataIdX + groupBySubFieldNameX orderByX groupByFieldMetadataIdY + groupBySubFieldNameY orderByY omitNullValues axisNameDisplay @@ -38,8 +40,10 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql` aggregateFieldMetadataId aggregateOperation groupByFieldMetadataIdX + groupBySubFieldNameX orderByX groupByFieldMetadataIdY + groupBySubFieldNameY orderByY omitNullValues axisNameDisplay @@ -55,6 +59,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql` groupByFieldMetadataId aggregateFieldMetadataId aggregateOperation + groupBySubFieldName orderBy displayDataLabel color diff --git a/packages/twenty-front/src/modules/page-layout/utils/getGroupByQueryName.ts b/packages/twenty-front/src/modules/page-layout/utils/getGroupByQueryName.ts new file mode 100644 index 00000000000..1f418401af5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/getGroupByQueryName.ts @@ -0,0 +1,7 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const getGroupByQueryName = ( + objectMetadataItem: ObjectMetadataItem, +): string => { + return `${objectMetadataItem.namePlural}GroupBy`; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx index ef2cedd22e9..6029fa0ba8e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetRenderer.tsx @@ -1,17 +1,10 @@ import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData'; import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; +import { GraphWidgetBarChartRenderer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer'; import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart'; import { lazy, Suspense } from 'react'; import { GraphType, type PageLayoutWidget } from '~/generated-metadata/graphql'; -const GraphWidgetBarChart = lazy(() => - import( - '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart' - ).then((module) => ({ - default: module.GraphWidgetBarChart, - })), -); - const GraphWidgetLineChart = lazy(() => import( '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart' @@ -100,21 +93,7 @@ export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => { ); case GraphType.BAR: - return ( - }> - - - ); + return ; case GraphType.LINE: return ( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx index c8b6d25c7cf..66f58fd2a5e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetBarChart.stories.tsx @@ -1,7 +1,7 @@ import { type Meta, type StoryObj } from '@storybook/react'; -import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'; +import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart', diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant.ts new file mode 100644 index 00000000000..e95c0561094 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant.ts @@ -0,0 +1 @@ +export const GRAPH_DEFAULT_AGGREGATE_VALUE = 0; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultColor.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultColor.constant.ts new file mode 100644 index 00000000000..bccce042e8f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultColor.constant.ts @@ -0,0 +1,3 @@ +import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; + +export const GRAPH_DEFAULT_COLOR: GraphColor = 'blue'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant.ts new file mode 100644 index 00000000000..478863f176a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant.ts @@ -0,0 +1,6 @@ +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-front/src/modules/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant.ts new file mode 100644 index 00000000000..08f36997fa0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant.ts @@ -0,0 +1 @@ +export const GRAPH_MAXIMUM_NUMBER_OF_GROUPS = 50; 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 new file mode 100644 index 00000000000..33484ccb41f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -0,0 +1,60 @@ +import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; +import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData'; +import { lazy, Suspense } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { + type BarChartConfiguration, + type PageLayoutWidget, +} from '~/generated/graphql'; + +const GraphWidgetBarChart = lazy(() => + import( + '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart' + ).then((module) => ({ + default: module.GraphWidgetBarChart, + })), +); + +export const GraphWidgetBarChartRenderer = ({ + widget, +}: { + widget: PageLayoutWidget; +}) => { + const { + data, + indexBy, + keys, + series, + xAxisLabel, + yAxisLabel, + showDataLabels, + loading, + error, + } = useGraphBarChartWidgetData({ + objectMetadataItemId: widget.objectMetadataId, + configuration: widget.configuration as BarChartConfiguration, + }); + + if (loading) { + return ; + } + + if (isDefined(error)) { + return
Error: {error.message}
; + } + + return ( + }> + + + ); +}; 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 78ca3787fb9..774464cd2b5 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 @@ -36,6 +36,7 @@ export const useBarChartData = ({ () => new Map(series?.map((s) => [s.key, s]) || []), [series], ); + const barConfigs = useMemo((): BarChartConfig[] => { return data.flatMap((dataPoint, dataIndex) => { const indexValue = dataPoint[indexBy]; 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 new file mode 100644 index 00000000000..952f0e18117 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -0,0 +1,60 @@ +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData'; +import { useMemo } from 'react'; +import { type BarChartConfiguration } from '~/generated/graphql'; + +type UseGraphBarChartWidgetDataProps = { + objectMetadataItemId: string; + configuration: BarChartConfiguration; +}; + +type UseGraphBarChartWidgetDataResult = { + data: BarChartDataItem[]; + indexBy: string; + keys: string[]; + series: BarChartSeries[]; + xAxisLabel?: string; + yAxisLabel?: string; + showDataLabels: boolean; + loading: boolean; + error?: Error; +}; + +export const useGraphBarChartWidgetData = ({ + objectMetadataItemId, + configuration, +}: UseGraphBarChartWidgetDataProps): UseGraphBarChartWidgetDataResult => { + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: objectMetadataItemId, + }); + + const { + data: groupByData, + loading, + error, + aggregateOperation, + } = useGraphWidgetGroupByQuery({ + objectMetadataItemId, + configuration, + }); + + const transformedData = useMemo( + () => + transformGroupByDataToBarChartData({ + groupByData, + objectMetadataItem, + configuration, + aggregateOperation, + }), + [groupByData, objectMetadataItem, configuration, aggregateOperation], + ); + + return { + ...transformedData, + loading, + error, + }; +}; 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 new file mode 100644 index 00000000000..49d7aee4133 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts @@ -0,0 +1,74 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields'; +import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery'; +import { generateGroupByQueryVariablesFromBarChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration'; +import { useQuery } from '@apollo/client'; +import { useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { type BarChartConfiguration } from '~/generated-metadata/graphql'; + +export const useGraphWidgetGroupByQuery = ({ + objectMetadataItemId, + configuration, +}: { + objectMetadataItemId: string; + configuration: BarChartConfiguration; +}) => { + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: objectMetadataItemId, + }); + + const availableAggregations = useMemo( + () => + getAvailableAggregationsFromObjectFields( + objectMetadataItem.readableFields, + ), + [objectMetadataItem.readableFields], + ); + + const aggregateFieldId = configuration.aggregateFieldMetadataId; + + const aggregateField = objectMetadataItem.readableFields.find( + (field) => field.id === aggregateFieldId, + ); + + if (!isDefined(aggregateField)) { + throw new Error('Aggregate field not found'); + } + + const aggregateOperation = + availableAggregations[aggregateField.name]?.[ + configuration.aggregateOperation + ]; + + if (!isDefined(aggregateOperation)) { + throw new Error('Aggregate operation not found'); + } + + const query = generateGroupByQuery({ + objectMetadataItem, + aggregateOperations: [aggregateOperation], + }); + + const variables = generateGroupByQueryVariablesFromBarChartConfiguration({ + objectMetadataItem, + barChartConfiguration: configuration, + }); + + const apolloCoreClient = useApolloCoreClient(); + + const { data, loading, error, refetch } = useQuery(query, { + client: apolloCoreClient, + variables, + }); + + return { + data, + loading, + error, + refetch, + variables, + aggregateOperation, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphColor.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphColor.ts index 6e40ef6ef42..1c38c0acaf5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphColor.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GraphColor.ts @@ -7,4 +7,5 @@ export type GraphColor = | 'yellow' | 'red' | 'green' - | 'sky'; + | 'sky' + | 'gray'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts new file mode 100644 index 00000000000..9858d4e272b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByRawResult.ts @@ -0,0 +1,4 @@ +export type GroupByRawResult = { + groupByDimensionValues: unknown[]; + [key: string]: unknown; +}; 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 new file mode 100644 index 00000000000..5ba76e78496 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-01-15T00:00:00.000Z 1`] = `"Q1 2024"`; + +exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-04-15T00:00:00.000Z 1`] = `"Q2 2024"`; + +exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-07-15T00:00:00.000Z 1`] = `"Q3 2024"`; + +exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-10-15T00:00:00.000Z 1`] = `"Q4 2024"`; + +exports[`formatDateByGranularity should format date for DAY granularity 1`] = `"Mar 20, 2024"`; + +exports[`formatDateByGranularity should format date for MONTH granularity 1`] = `"March 2024"`; + +exports[`formatDateByGranularity should format date for NONE granularity 1`] = `"3/20/2024"`; + +exports[`formatDateByGranularity should format date for QUARTER granularity 1`] = `"Q1 2024"`; + +exports[`formatDateByGranularity should format date for YEAR granularity 1`] = `"2024"`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap new file mode 100644 index 00000000000..0632267999b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateGroupByQuery should generate valid GraphQL query for empty aggregate operations 1`] = ` +" + query PeopleGroupBy( + $groupBy: [PersonGroupByInput!] + $filter: PersonFilterInput + $orderBy: [PersonOrderByWithGroupByInput!] + $viewId: UUID + ) { + peopleGroupBy( + groupBy: $groupBy + filter: $filter + orderBy: $orderBy + viewId: $viewId + ) { + groupByDimensionValues + } + } + " +`; + +exports[`generateGroupByQuery should generate valid GraphQL query for multiple aggregate operations 1`] = ` +" + query OpportunitiesGroupBy( + $groupBy: [OpportunityGroupByInput!] + $filter: OpportunityFilterInput + $orderBy: [OpportunityOrderByWithGroupByInput!] + $viewId: UUID + ) { + opportunitiesGroupBy( + groupBy: $groupBy + filter: $filter + orderBy: $orderBy + viewId: $viewId + ) { + groupByDimensionValues + totalCount + sumAmountAmountMicros + avgAmountAmountMicros + } + } + " +`; + +exports[`generateGroupByQuery should generate valid GraphQL query for single aggregate operation 1`] = ` +" + query OpportunitiesGroupBy( + $groupBy: [OpportunityGroupByInput!] + $filter: OpportunityFilterInput + $orderBy: [OpportunityOrderByWithGroupByInput!] + $viewId: UUID + ) { + opportunitiesGroupBy( + groupBy: $groupBy + filter: $filter + orderBy: $orderBy + viewId: $viewId + ) { + groupByDimensionValues + sumAmountAmountMicros + } + } + " +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts.snap new file mode 100644 index 00000000000..14afcd0ef6d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with composite field 1`] = ` +{ + "groupBy": [ + { + "name": { + "firstName": true, + }, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with single groupBy field 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with two groupBy fields 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + { + "ownerId": 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 new file mode 100644 index 00000000000..7367f6538fb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts @@ -0,0 +1,104 @@ +import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('buildGroupByFieldObject', () => { + it('should return field with Id suffix for relation fields', () => { + const field = { + name: 'company', + type: FieldMetadataType.RELATION, + } as any; + + const result = buildGroupByFieldObject({ field }); + + expect(result).toEqual({ companyId: true }); + }); + + 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 }); + + expect(result).toEqual({ + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.DAY, + }, + }); + }); + + 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 }); + + expect(result).toEqual({ + updatedAt: { + granularity: ObjectRecordGroupByDateGranularity.DAY, + }, + }); + }); + + 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, + }); + + expect(result).toEqual({ + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.MONTH, + }, + }); + }); + + 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 }); + }); +}); 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 new file mode 100644 index 00000000000..e62ce702df3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeAggregateNumericValueForGraph.test.ts @@ -0,0 +1,71 @@ +import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { computeAggregateNumericValueForGraph } from '../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__/formatDateByGranularity.spec.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts new file mode 100644 index 00000000000..0da6f267ae7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts @@ -0,0 +1,79 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { formatDateByGranularity } from '../formatDateByGranularity'; + +describe('formatDateByGranularity', () => { + const testDate = new Date('2024-03-20T12:00:00Z'); + + beforeAll(() => { + // Mock toLocaleDateString to avoid timezone/locale issues + jest + .spyOn(Date.prototype, 'toLocaleDateString') + .mockImplementation((_locales, options) => { + if (options?.weekday === 'long') return 'Wednesday'; + if (options?.month === 'long' && !isDefined(options?.year)) + return 'March'; + if (options?.month === 'long' && isDefined(options?.year)) + return 'March 2024'; + if (options?.month === 'short') return 'Mar 20, 2024'; + return '3/20/2024'; + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + const testCases: { + granularity: + | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.MONTH + | ObjectRecordGroupByDateGranularity.QUARTER + | ObjectRecordGroupByDateGranularity.YEAR + | ObjectRecordGroupByDateGranularity.NONE; + }[] = [ + { granularity: ObjectRecordGroupByDateGranularity.DAY }, + { granularity: ObjectRecordGroupByDateGranularity.MONTH }, + { granularity: ObjectRecordGroupByDateGranularity.QUARTER }, + { granularity: ObjectRecordGroupByDateGranularity.YEAR }, + { granularity: ObjectRecordGroupByDateGranularity.NONE }, + ]; + + it.each(testCases)( + 'should format date for $granularity granularity', + ({ granularity }) => { + expect(formatDateByGranularity(testDate, granularity)).toMatchSnapshot(); + }, + ); + + describe('quarter calculations', () => { + const quarterTestCases: { + date: Date; + granularity: ObjectRecordGroupByDateGranularity.QUARTER; + }[] = [ + { + date: new Date('2024-01-15'), + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + date: new Date('2024-04-15'), + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + date: new Date('2024-07-15'), + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + date: new Date('2024-10-15'), + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + ]; + + it.each(quarterTestCases)( + 'should calculate quarter for $granularity with date $date', + ({ date, granularity }) => { + expect(formatDateByGranularity(date, granularity)).toMatchSnapshot(); + }, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQuery.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQuery.test.ts new file mode 100644 index 00000000000..b7aeb2f8caa --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQuery.test.ts @@ -0,0 +1,47 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { generateGroupByQuery } from '../generateGroupByQuery'; + +describe('generateGroupByQuery', () => { + const testCases = [ + { + description: 'single aggregate operation', + objectMetadataItem: { + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + aggregateOperations: ['sumAmountAmountMicros'], + }, + { + description: 'multiple aggregate operations', + objectMetadataItem: { + nameSingular: 'opportunity', + namePlural: 'opportunities', + }, + aggregateOperations: [ + 'totalCount', + 'sumAmountAmountMicros', + 'avgAmountAmountMicros', + ], + }, + { + description: 'empty aggregate operations', + objectMetadataItem: { + nameSingular: 'person', + namePlural: 'people', + }, + aggregateOperations: [], + }, + ]; + + it.each(testCases)( + 'should generate valid GraphQL query for $description', + ({ objectMetadataItem, aggregateOperations }) => { + const result = generateGroupByQuery({ + objectMetadataItem: objectMetadataItem as ObjectMetadataItem, + aggregateOperations, + }); + + expect(result.loc?.source.body).toMatchSnapshot(); + }, + ); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts new file mode 100644 index 00000000000..b0e5f8d2407 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts @@ -0,0 +1,86 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + type BarChartConfiguration, + FieldMetadataType, +} from '~/generated-metadata/graphql'; +import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration'; + +describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + id: 'field-1', + name: 'stage', + type: FieldMetadataType.TEXT, + }, + { + id: 'field-2', + name: 'owner', + type: FieldMetadataType.RELATION, + }, + { + id: 'field-3', + name: 'createdAt', + type: FieldMetadataType.DATE_TIME, + }, + { + id: 'field-4', + name: 'name', + type: FieldMetadataType.FULL_NAME, + }, + ], + } as ObjectMetadataItem; + + it('should generate variables with single groupBy field', () => { + const result = generateGroupByQueryVariablesFromBarChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + barChartConfiguration: { + groupByFieldMetadataIdX: 'field-1', + groupBySubFieldNameX: null, + } as BarChartConfiguration, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with two groupBy fields', () => { + const result = generateGroupByQueryVariablesFromBarChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + barChartConfiguration: { + groupByFieldMetadataIdX: 'field-1', + groupBySubFieldNameX: null, + groupByFieldMetadataIdY: 'field-2', + groupBySubFieldNameY: null, + } as BarChartConfiguration, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with composite field', () => { + const result = generateGroupByQueryVariablesFromBarChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + barChartConfiguration: { + groupByFieldMetadataIdX: 'field-4', + groupBySubFieldNameX: 'firstName', + } as BarChartConfiguration, + }); + + expect(result).toMatchSnapshot(); + }); + + it('should throw error when groupBy field X not found', () => { + expect(() => + generateGroupByQueryVariablesFromBarChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + barChartConfiguration: { + groupByFieldMetadataIdX: 'invalid-field', + groupBySubFieldNameX: null, + } as BarChartConfiguration, + }), + ).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__/getFieldKey.spec.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts new file mode 100644 index 00000000000..caf5ed47639 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/getFieldKey.spec.ts @@ -0,0 +1,32 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { getFieldKey } from '../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/buildGroupByFieldObject.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts new file mode 100644 index 00000000000..13c1f702f6e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts @@ -0,0 +1,52 @@ +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.constant'; +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const buildGroupByFieldObject = ({ + field, + subFieldName, + dateGranularity, +}: { + field: FieldMetadataItem; + subFieldName?: string | null; + dateGranularity?: ObjectRecordGroupByDateGranularity; +}): Record> => { + const isRelation = isFieldRelation(field) || isFieldMorphRelation(field); + const isComposite = isCompositeFieldType(field.type); + const isDateField = + field.type === FieldMetadataType.DATE || + field.type === FieldMetadataType.DATE_TIME; + + if (isRelation) { + return { [`${field.name}Id`]: true }; + } + + if (isComposite) { + if (!isDefined(subFieldName)) { + throw new Error( + `Composite field ${field.name} requires a subfield to be specified`, + ); + } + return { + [field.name]: { + [subFieldName]: true, + }, + }; + } + + if (isDateField) { + return { + [field.name]: { + granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + }, + }; + } + + return { [field.name]: true }; +}; 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 new file mode 100644 index 00000000000..7cf475d7a1c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph.ts @@ -0,0 +1,77 @@ +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.constant'; +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: + 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 new file mode 100644 index 00000000000..df07cb4330e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult.ts @@ -0,0 +1,38 @@ +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/createGraphColorRegistry.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createGraphColorRegistry.ts index 90ccd73f13a..8d11745d13f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createGraphColorRegistry.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createGraphColorRegistry.ts @@ -80,4 +80,12 @@ export const createGraphColorRegistry = ( }, solid: theme.color.sky, }, + gray: { + name: 'gray', + gradient: { + normal: [theme.adaptiveColors.gray1, theme.adaptiveColors.gray2], + hover: [theme.adaptiveColors.gray3, theme.adaptiveColors.gray4], + }, + solid: theme.color.gray, + }, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts new file mode 100644 index 00000000000..a7efb04be7a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts @@ -0,0 +1,32 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +export const formatDateByGranularity = ( + date: Date, + granularity: + | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.MONTH + | ObjectRecordGroupByDateGranularity.QUARTER + | ObjectRecordGroupByDateGranularity.YEAR + | ObjectRecordGroupByDateGranularity.NONE, +): string => { + switch (granularity) { + case ObjectRecordGroupByDateGranularity.DAY: + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + case ObjectRecordGroupByDateGranularity.MONTH: + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + }); + case ObjectRecordGroupByDateGranularity.QUARTER: + return `Q${Math.floor(date.getMonth() / 3) + 1} ${date.getFullYear()}`; + case ObjectRecordGroupByDateGranularity.YEAR: + return date.getFullYear().toString(); + case ObjectRecordGroupByDateGranularity.NONE: + default: + return date.toLocaleDateString(); + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts new file mode 100644 index 00000000000..3a5d8039f4e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts @@ -0,0 +1,91 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant'; +import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity'; +import { t } from '@lingui/core/macro'; +import { + FieldMetadataType, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +type FormatDimensionValueParams = { + value: unknown; + fieldMetadata: FieldMetadataItem; + dateGranularity?: ObjectRecordGroupByDateGranularity; +}; + +const normalizeMultiSelectValue = (value: unknown): unknown[] => { + if (Array.isArray(value)) { + return value; + } + + if (typeof value !== 'string') { + return [value]; + } + + const trimmed = value.trim(); + const isPostgresArrayFormat = + trimmed.startsWith('{') && trimmed.endsWith('}'); + + if (!isPostgresArrayFormat) { + return [value]; + } + + const content = trimmed.slice(1, -1); + return content ? content.split(',') : []; +}; + +export const formatDimensionValue = ({ + value, + fieldMetadata, + dateGranularity = GRAPH_DEFAULT_DATE_GRANULARITY as ObjectRecordGroupByDateGranularity, +}: FormatDimensionValueParams): string => { + if (!isDefined(value)) { + return ''; + } + + switch (fieldMetadata.type) { + case FieldMetadataType.SELECT: { + const selectedOption = fieldMetadata.options?.find( + (option) => option.value === value, + ); + return selectedOption?.label ?? String(value); + } + + case FieldMetadataType.MULTI_SELECT: { + const values = normalizeMultiSelectValue(value); + + return values + .map((value) => { + const option = fieldMetadata.options?.find( + (option) => option.value === value, + ); + return option?.label ?? String(value); + }) + .join(', '); + } + + case FieldMetadataType.BOOLEAN: { + return value === true ? t`Yes` : t`No`; + } + + case FieldMetadataType.DATE: + case FieldMetadataType.DATE_TIME: { + // TODO: granularity will be passed from the graph configuration when implemented + if ( + dateGranularity === + ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK || + dateGranularity === + ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR || + dateGranularity === + ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR + ) { + return String(value); + } + return formatDateByGranularity(new Date(String(value)), dateGranularity); + } + + default: + return String(value); + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts new file mode 100644 index 00000000000..62545b7c9e0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { capitalize } from 'twenty-shared/utils'; +import { getGroupByQueryName } from '../../../utils/getGroupByQueryName'; + +export const generateGroupByQuery = ({ + objectMetadataItem, + aggregateOperations, +}: { + objectMetadataItem: ObjectMetadataItem; + aggregateOperations: string[]; +}) => { + const capitalizedSingular = capitalize(objectMetadataItem.nameSingular); + const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupBy`; + const queryFieldName = getGroupByQueryName(objectMetadataItem); + + return gql` + query ${queryName}( + $groupBy: [${capitalizedSingular}GroupByInput!] + $filter: ${capitalizedSingular}FilterInput + $orderBy: [${capitalizedSingular}OrderByWithGroupByInput!] + $viewId: UUID + ) { + ${queryFieldName}( + groupBy: $groupBy + filter: $filter + orderBy: $orderBy + viewId: $viewId + ) { + groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''} + } + } + `; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts new file mode 100644 index 00000000000..11c5e9c6372 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts @@ -0,0 +1,86 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql'; +import { buildGroupByFieldObject } from './buildGroupByFieldObject'; + +const _mapOrderByToDirection = (orderByEnum: GraphOrderBy) => { + switch (orderByEnum) { + case GraphOrderBy.FIELD_ASC: + return 'AscNullsLast'; + case GraphOrderBy.FIELD_DESC: + return 'DescNullsLast'; + case GraphOrderBy.VALUE_ASC: + return 'AscNullsLast'; + case GraphOrderBy.VALUE_DESC: + return 'DescNullsLast'; + default: + assertUnreachable(orderByEnum); + } +}; + +export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ + objectMetadataItem, + barChartConfiguration, +}: { + objectMetadataItem: ObjectMetadataItem; + barChartConfiguration: BarChartConfiguration; +}) => { + const groupByFieldX = objectMetadataItem.fields.find( + (field) => field.id === barChartConfiguration.groupByFieldMetadataIdX, + ); + + const groupByFieldY = objectMetadataItem.fields.find( + (field) => field.id === barChartConfiguration.groupByFieldMetadataIdY, + ); + + if (!isDefined(groupByFieldX)) { + throw new Error( + `Field with id ${barChartConfiguration.groupByFieldMetadataIdX} not found in object metadata`, + ); + } + + const groupBy: Array< + Record> + > = []; + + groupBy.push( + buildGroupByFieldObject({ + field: groupByFieldX, + subFieldName: barChartConfiguration.groupBySubFieldNameX, + }), + ); + + if (isDefined(groupByFieldY)) { + groupBy.push( + buildGroupByFieldObject({ + field: groupByFieldY, + subFieldName: barChartConfiguration.groupBySubFieldNameY, + }), + ); + } + + const orderBy: Array> = []; + + // TODO: Add orderBy back in when the backend is ready + // if (isDefined(barChartConfiguration.orderByX)) { + // orderBy.push({ + // [groupByFieldX.name]: mapOrderByToDirection( + // barChartConfiguration.orderByX, + // ), + // }); + // } + + // if (isDefined(groupByFieldY) && isDefined(barChartConfiguration.orderByY)) { + // orderBy.push({ + // [groupByFieldY.name]: mapOrderByToDirection( + // barChartConfiguration.orderByY, + // ), + // }); + // } + + return { + groupBy, + // TODO: Add filters + ...(orderBy.length > 0 && { orderBy }), + }; +}; 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 new file mode 100644 index 00000000000..c3e3f674d3d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getFieldKey.ts @@ -0,0 +1,20 @@ +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/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts new file mode 100644 index 00000000000..824758b9c03 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts @@ -0,0 +1,133 @@ +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 { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName'; +import { GRAPH_MAXIMUM_NUMBER_OF_GROUPS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant'; +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; +import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData'; +import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData'; +import { isDefined } from 'twenty-shared/utils'; +import { + AxisNameDisplay, + type BarChartConfiguration, +} from '~/generated/graphql'; + +type TransformGroupByDataToBarChartDataParams = { + groupByData: Record | null | undefined; + objectMetadataItem: ObjectMetadataItem; + configuration: BarChartConfiguration; + aggregateOperation: string; +}; + +type TransformGroupByDataToBarChartDataResult = { + data: BarChartDataItem[]; + indexBy: string; + keys: string[]; + series: BarChartSeries[]; + xAxisLabel?: string; + yAxisLabel?: string; + showDataLabels: boolean; +}; + +const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = { + data: [], + indexBy: '', + keys: [], + series: [], + xAxisLabel: undefined, + yAxisLabel: undefined, + showDataLabels: false, +}; + +export const transformGroupByDataToBarChartData = ({ + groupByData, + objectMetadataItem, + configuration, + aggregateOperation, +}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => { + if (!isDefined(groupByData)) { + return EMPTY_BAR_CHART_RESULT; + } + + const groupByFieldX = objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.groupByFieldMetadataIdX, + ); + + const groupByFieldY = objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.groupByFieldMetadataIdY, + ); + + const aggregateField = objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.aggregateFieldMetadataId, + ); + + if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) { + return EMPTY_BAR_CHART_RESULT; + } + + const indexByKey = getFieldKey({ + field: groupByFieldX, + subFieldName: configuration.groupBySubFieldNameX, + }); + + const queryName = getGroupByQueryName(objectMetadataItem); + const queryResults = groupByData[queryName]; + + if (!isDefined(queryResults) || !Array.isArray(queryResults)) { + return { + ...EMPTY_BAR_CHART_RESULT, + indexBy: indexByKey, + }; + } + + // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) + const rawResults = queryResults.slice(0, GRAPH_MAXIMUM_NUMBER_OF_GROUPS); + + const showXAxis = + configuration.axisNameDisplay === AxisNameDisplay.X || + configuration.axisNameDisplay === AxisNameDisplay.BOTH; + + const showYAxis = + configuration.axisNameDisplay === AxisNameDisplay.Y || + configuration.axisNameDisplay === AxisNameDisplay.BOTH; + + const xAxisLabel = showXAxis ? groupByFieldX.label : undefined; + + const yAxisLabel = showYAxis + ? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}` + : undefined; + + const showDataLabels = configuration.displayDataLabel; + + const baseResult = isDefined(groupByFieldY) + ? transformTwoDimensionalGroupByToBarChartData({ + rawResults, + groupByFieldX, + groupByFieldY, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + }) + : transformOneDimensionalGroupByToBarChartData({ + rawResults, + groupByFieldX, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + }); + + return { + ...baseResult, + xAxisLabel, + yAxisLabel, + showDataLabels, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts new file mode 100644 index 00000000000..78efcd9a2a5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts @@ -0,0 +1,83 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant'; +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; +import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; +import { isDefined } from 'twenty-shared/utils'; +import { type BarChartConfiguration } from '~/generated/graphql'; + +type TransformOneDimensionalGroupByToBarChartDataParams = { + rawResults: GroupByRawResult[]; + groupByFieldX: FieldMetadataItem; + aggregateField: FieldMetadataItem; + configuration: BarChartConfiguration; + aggregateOperation: string; + objectMetadataItem: ObjectMetadataItem; +}; + +type TransformOneDimensionalGroupByToBarChartDataResult = { + data: BarChartDataItem[]; + indexBy: string; + keys: string[]; + series: BarChartSeries[]; +}; + +export const transformOneDimensionalGroupByToBarChartData = ({ + rawResults, + groupByFieldX, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, +}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => { + const indexByKey = getFieldKey({ + field: groupByFieldX, + subFieldName: configuration.groupBySubFieldNameX, + }); + + const data: BarChartDataItem[] = rawResults.map((result) => { + const dimensionValues = result.groupByDimensionValues; + + const xValue = isDefined(dimensionValues?.[0]) + ? formatDimensionValue({ + value: dimensionValues[0], + fieldMetadata: groupByFieldX, + }) + : ''; + + const aggregateValue = computeAggregateValueFromGroupByResult({ + rawResult: result, + aggregateField, + aggregateOperation: + configuration.aggregateOperation as unknown as ExtendedAggregateOperations, + aggregateOperationFromRawResult: aggregateOperation, + objectMetadataItem, + }); + + return { + [indexByKey]: xValue, + [aggregateField.name]: aggregateValue, + }; + }); + + const series: BarChartSeries[] = [ + { + key: aggregateField.name, + label: aggregateField.label, + color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor, + }, + ]; + + return { + data, + indexBy: indexByKey, + keys: [aggregateField.name], + series, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts new file mode 100644 index 00000000000..fc6edf88ce9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts @@ -0,0 +1,93 @@ +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 BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; +import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; +import { isDefined } 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; +}; + +type TransformTwoDimensionalGroupByToBarChartDataResult = { + data: BarChartDataItem[]; + indexBy: string; + keys: string[]; + series: BarChartSeries[]; +}; + +export const transformTwoDimensionalGroupByToBarChartData = ({ + rawResults, + groupByFieldX, + groupByFieldY, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, +}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => { + const indexByKey = getFieldKey({ + field: groupByFieldX, + subFieldName: configuration.groupBySubFieldNameX, + }); + + const dataMap = new Map(); + const yValues = new Set(); + + rawResults.forEach((result) => { + const dimensionValues = result.groupByDimensionValues; + if (!isDefined(dimensionValues) || dimensionValues.length < 2) return; + + const xValue = formatDimensionValue({ + value: dimensionValues[0], + fieldMetadata: groupByFieldX, + }); + const yValue = formatDimensionValue({ + value: dimensionValues[1], + fieldMetadata: groupByFieldY, + }); + + const aggregateValue = computeAggregateValueFromGroupByResult({ + rawResult: result, + aggregateField, + aggregateOperation: + configuration.aggregateOperation as unknown as ExtendedAggregateOperations, + aggregateOperationFromRawResult: aggregateOperation, + objectMetadataItem, + }); + + yValues.add(yValue); + + if (!dataMap.has(xValue)) { + dataMap.set(xValue, { + [indexByKey]: xValue, + }); + } + + const dataItem = dataMap.get(xValue)!; + dataItem[yValue] = aggregateValue; + }); + + const keys = Array.from(yValues); + const series: BarChartSeries[] = keys.map((key) => ({ + key, + label: key, + })); + + return { + data: Array.from(dataMap.values()), + indexBy: indexByKey, + keys, + series, + }; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts index 172e3de5ca0..41d5bda0952 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts @@ -1,4 +1,4 @@ -import { type ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts index cbb017733aa..e20d8ae6dd3 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts @@ -1,9 +1,7 @@ import { t } from '@lingui/core/macro'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { - ObjectRecordGroupByDateGranularity, - type ObjectRecord, -} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts index d096dcb71f0..c993ac2e23b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts @@ -1,7 +1,6 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; -import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; - import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts index d82ed309025..93431d3d859 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts @@ -1,7 +1,9 @@ import { isDefined } from 'class-validator'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { + FieldMetadataType, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; -import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index 1635f05ef5b..5d12b0944a1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -1,3 +1,5 @@ +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + //TODO : Refacto-common - Should be moved to common api layer export interface ObjectRecord { id: string; @@ -40,17 +42,6 @@ export enum OrderByDirection { DescNullsLast = 'DescNullsLast', } -export enum ObjectRecordGroupByDateGranularity { - DAY = 'DAY', - MONTH = 'MONTH', - QUARTER = 'QUARTER', - YEAR = 'YEAR', - DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', - QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', - MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR', - NONE = 'NONE', -} - export type ObjectRecordOrderBy = Array< ObjectRecordOrderByForScalarField | ObjectRecordOrderByForCompositeField >; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts index be7e010d1e2..e644681e6d9 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'; - -import { ObjectRecordGroupByDateGranularity } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; diff --git a/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts b/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts new file mode 100644 index 00000000000..4a9513bcc6b --- /dev/null +++ b/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts @@ -0,0 +1,10 @@ +export enum ObjectRecordGroupByDateGranularity { + DAY = 'DAY', + MONTH = 'MONTH', + QUARTER = 'QUARTER', + YEAR = 'YEAR', + DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', + MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR', + QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', + NONE = 'NONE', +} diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 2863d238f30..ba5ede770d5 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -31,6 +31,7 @@ export type { Nullable } from './Nullable'; export type { NullablePartial } from './NullablePartial'; export type { ObjectPermissions } from './ObjectPermissions'; export type { ObjectPermissionsDeprecated } from './ObjectPermissionsDeprecated'; +export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGranularity'; export type { ObjectsPermissions } from './ObjectsPermissions'; export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId'; export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated';