diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 4a8b6ae5f7..9aa68d15d5 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1691,6 +1691,7 @@ export type LineChartConfiguration = { filter?: Maybe; firstDayOfTheWeek?: Maybe; graphType: GraphType; + isStacked?: Maybe; omitNullValues?: Maybe; primaryAxisDateGranularity?: Maybe; primaryAxisGroupByFieldMetadataId: Scalars['UUID']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 50a2ad8126..b25e257873 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1625,6 +1625,7 @@ export type LineChartConfiguration = { filter?: Maybe; firstDayOfTheWeek?: Maybe; graphType: GraphType; + isStacked?: Maybe; omitNullValues?: Maybe; primaryAxisDateGranularity?: Maybe; primaryAxisGroupByFieldMetadataId: Scalars['UUID']; @@ -4656,7 +4657,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?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | 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?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null }; export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{ id: Scalars['String']; @@ -4664,7 +4665,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?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | 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?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | null }> | null }> | null } }; export type OnDbEventSubscriptionVariables = Exact<{ input: OnDbEventInput; @@ -5019,6 +5020,7 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql` color description filter + isStacked timezone firstDayOfTheWeek } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx index eda2c47049..bb565310a1 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx @@ -18,6 +18,7 @@ import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/s import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; +import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant'; import { hasWidgetTooManyGroupsComponentState } from '@/page-layout/widgets/graph/states/hasWidgetTooManyGroupsComponentState'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; @@ -89,7 +90,8 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { if ( graphType !== GraphType.VERTICAL_BAR && - graphType !== GraphType.HORIZONTAL_BAR + graphType !== GraphType.HORIZONTAL_BAR && + graphType !== GraphType.LINE ) { setHasWidgetTooManyGroups(false); } @@ -124,7 +126,11 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { /> {hasWidgetTooManyGroups && ( )} {chartSettings.map((group) => { 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 f10a08860f..95ecff8964 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 @@ -104,19 +104,29 @@ export const ChartGroupByFieldSelectionDropdownContentBase = < [subFieldNameKey]: subFieldName, }; - if ( - !isSecondaryAxis || - configuration.__typename !== 'BarChartConfiguration' - ) { + if (!isSecondaryAxis) { return baseConfig; } - return { - ...baseConfig, - groupMode: isDefined(fieldId) - ? (configuration.groupMode ?? BarChartGroupMode.STACKED) - : null, - }; + if (configuration.__typename === 'BarChartConfiguration') { + return { + ...baseConfig, + groupMode: isDefined(fieldId) + ? (configuration.groupMode ?? BarChartGroupMode.STACKED) + : null, + }; + } + + if (configuration.__typename === 'LineChartConfiguration') { + return { + ...baseConfig, + isStacked: isDefined(fieldId) + ? (configuration.isStacked ?? true) + : null, + }; + } + + return baseConfig; }; const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartXAxisSortBySelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartXAxisSortBySelectionDropdownContent.tsx index 6fb2d062b8..7cf168650e 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartXAxisSortBySelectionDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartXAxisSortBySelectionDropdownContent.tsx @@ -56,14 +56,22 @@ export const ChartXAxisSortBySelectionDropdownContent = () => { closeDropdown(); }; + const isLineChart = configuration.__typename === 'LineChartConfiguration'; + const availableOptions = X_SORT_BY_OPTIONS.filter((option) => { + if (isLineChart) { + return option.value !== 'VALUE_ASC' && option.value !== 'VALUE_DESC'; + } + return true; + }); + return ( option.value)} + selectableItemIdArray={availableOptions.map((option) => option.value)} > - {X_SORT_BY_OPTIONS.map((sortOption) => ( + {availableOptions.map((sortOption) => ( { onClick={handleClick} > { deletePageLayoutWidget(widget.id); }; - const [isHovered, setIsHovered] = useState(false); + const setIsHovered = useSetRecoilComponentFamilyState( + widgetCardHoveredComponentFamilyState, + widget.id, + ); const handleMouseEnter = () => { setIsHovered(true); @@ -95,7 +100,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { > {showHeader && ( void; + instanceId?: string; +}) => { + const instanceId = instanceIdFromProps ?? GRAPH_WIDGET_TEST_INSTANCE_ID; + + return ( + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx index afa7f77d90..5bb7a92b9c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidget.tsx @@ -4,18 +4,11 @@ import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; import { GraphWidgetAggregateChartRenderer } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/components/GraphWidgetAggregateChartRenderer'; import { GraphWidgetBarChartRenderer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer'; +import { GraphWidgetLineChartRenderer } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer'; import { areChartConfigurationFieldsValidForQuery } from '@/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery'; import { lazy, Suspense } from 'react'; import { GraphType, type PageLayoutWidget } from '~/generated/graphql'; -const GraphWidgetLineChart = lazy(() => - import( - '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart' - ).then((module) => ({ - default: module.GraphWidgetLineChart, - })), -); - const GraphWidgetPieChart = lazy(() => import( '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart' @@ -100,28 +93,7 @@ export const GraphWidget = ({ return ; case GraphType.LINE: - return ( - }> - - - ); + return ; default: return null; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartHasTooManyGroupsEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect.tsx similarity index 77% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartHasTooManyGroupsEffect.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect.tsx index 915a10f42e..f3f2f367f7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartHasTooManyGroupsEffect.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect.tsx @@ -2,13 +2,13 @@ import { hasWidgetTooManyGroupsComponentState } from '@/page-layout/widgets/grap import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useEffect } from 'react'; -type GraphWidgetBarChartHasTooManyGroupsEffectProps = { +type GraphWidgetChartHasTooManyGroupsEffectProps = { hasTooManyGroups: boolean; }; -export const GraphWidgetBarChartHasTooManyGroupsEffect = ({ +export const GraphWidgetChartHasTooManyGroupsEffect = ({ hasTooManyGroups, -}: GraphWidgetBarChartHasTooManyGroupsEffectProps) => { +}: GraphWidgetChartHasTooManyGroupsEffectProps) => { const setHasWidgetTooManyGroups = useSetRecoilComponentState( hasWidgetTooManyGroupsComponentState, ); 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 e1a4aed828..e2b1161532 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,5 +1,6 @@ import { type Meta, type StoryObj } from '@storybook/react'; +import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'; import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; @@ -7,7 +8,14 @@ import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart', component: GraphWidgetBarChart, - decorators: [ComponentDecorator], + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], parameters: { layout: 'centered', }, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx index f421e15aa7..38b1743e0f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx @@ -1,13 +1,21 @@ import { type Meta, type StoryObj } from '@storybook/react'; - -import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'; import { type ComponentProps } from 'react'; + +import { GraphWidgetTestWrapper } from '@/page-layout/widgets/graph/__tests__/GraphWidgetTestWrapper'; +import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart'; import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; const meta: Meta = { title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart', component: GraphWidgetLineChart, - decorators: [ComponentDecorator], + decorators: [ + (Story) => ( + + + + ), + ComponentDecorator, + ], parameters: { layout: 'centered', }, @@ -34,7 +42,7 @@ const meta: Meta = { showGrid: { control: 'boolean', }, - enablePoints: { + enablePointLabel: { control: 'boolean', }, xAxisLabel: { @@ -43,29 +51,14 @@ const meta: Meta = { yAxisLabel: { control: 'text', }, - enableArea: { - control: 'boolean', - }, - stackedArea: { - control: 'boolean', - }, - curve: { - control: 'select', - options: [ - 'linear', - 'monotoneX', - 'step', - 'stepBefore', - 'stepAfter', - 'natural', - ], - }, - lineWidth: { + rangeMin: { control: 'number', }, - enableSlices: { - control: 'select', - options: ['x', 'y', false], + rangeMax: { + control: 'number', + }, + omitNullValues: { + control: 'boolean', }, }, }; @@ -85,20 +78,16 @@ const renderChart = (args: ChartArgs) => ( data={args.data} showLegend={args.showLegend} showGrid={args.showGrid} - enablePoints={args.enablePoints} + enablePointLabel={args.enablePointLabel} xAxisLabel={args.xAxisLabel} yAxisLabel={args.yAxisLabel} + rangeMin={args.rangeMin} + rangeMax={args.rangeMax} + omitNullValues={args.omitNullValues} displayType={args.displayType} prefix={args.prefix} suffix={args.suffix} decimals={args.decimals} - enableArea={args.enableArea} - stackedArea={args.stackedArea} - curve={args.curve} - lineWidth={args.lineWidth} - enableSlices={args.enableSlices} - xScale={args.xScale} - yScale={args.yScale} /> ); @@ -129,7 +118,6 @@ export const Default: Story = { ], showLegend: true, showGrid: true, - enablePoints: false, xAxisLabel: 'Month', yAxisLabel: 'Value', displayType: 'shortNumber', @@ -156,10 +144,8 @@ export const WithArea: Story = { data: generateLinearData(12), }, ], - enableArea: true, showLegend: true, showGrid: true, - enablePoints: false, xAxisLabel: 'Period', yAxisLabel: 'Amount', displayType: 'currency', @@ -190,46 +176,15 @@ export const StackedArea: Story = { data: generateLinearData(8), }, ], - enableArea: true, - stackedArea: true, showLegend: true, showGrid: true, - enablePoints: false, - curve: 'monotoneX', xAxisLabel: 'Quarter', yAxisLabel: 'Revenue', - yScale: { - type: 'linear', - min: 0, - max: 'auto', - }, displayType: 'shortNumber', prefix: '$', }, }; -export const WithPoints: Story = { - render: renderChart, - args: { - id: 'line-chart-points', - data: [ - { - id: 'performance', - label: 'Performance', - color: 'pink', - data: generateLinearData(8), - }, - ], - showLegend: true, - showGrid: true, - enablePoints: true, - lineWidth: 3, - xAxisLabel: 'Week', - yAxisLabel: 'Score', - displayType: 'percentage', - }, -}; - export const StepChart: Story = { render: renderChart, args: { @@ -242,11 +197,8 @@ export const StepChart: Story = { data: generateLinearData(10), }, ], - curve: 'step', showLegend: true, showGrid: true, - enablePoints: false, - lineWidth: 2, xAxisLabel: 'Day', yAxisLabel: 'Units', displayType: 'number', @@ -287,15 +239,8 @@ export const LogScaleDemo: Story = { ], showLegend: true, showGrid: true, - enablePoints: true, xAxisLabel: 'Time', yAxisLabel: 'Value (log scale)', - yScale: { - type: 'log', - base: 10, - min: 'auto', - max: 'auto', - }, displayType: 'shortNumber', }, }; @@ -322,42 +267,12 @@ export const WithNullValues: Story = { ], showLegend: true, showGrid: true, - enablePoints: true, xAxisLabel: 'Time', yAxisLabel: 'Measurement', displayType: 'number', }, }; -export const InteractiveWithLinks: Story = { - render: renderChart, - args: { - id: 'line-chart-interactive', - data: [ - { - id: 'clickable', - label: 'Click Points', - color: 'turquoise', - data: [ - { x: 0, y: 30, to: '#point-0' }, - { x: 1, y: 45, to: '#point-1' }, - { x: 2, y: 38, to: '#point-2' }, - { x: 3, y: 52, to: '#point-3' }, - { x: 4, y: 48, to: '#point-4' }, - { x: 5, y: 60, to: '#point-5' }, - ], - }, - ], - showLegend: true, - showGrid: true, - enablePoints: true, - enableSlices: 'x', - xAxisLabel: 'Step', - yAxisLabel: 'Progress', - displayType: 'percentage', - }, -}; - export const MultiSeriesMixed: Story = { render: renderChart, args: { @@ -368,14 +283,12 @@ export const MultiSeriesMixed: Story = { label: 'Actual', color: 'blue', data: generateLinearData(12), - enableArea: true, }, { id: 'forecast', label: 'Forecast', color: 'purple', data: generateLinearData(12), - enableArea: false, }, { id: 'target', @@ -385,18 +298,13 @@ export const MultiSeriesMixed: Story = { ...dataPoint, y: 75, })), - enableArea: false, }, ], - enableArea: false, showLegend: true, showGrid: true, - enablePoints: false, - curve: 'monotoneX', xAxisLabel: 'Month', yAxisLabel: 'Value', displayType: 'shortNumber', - enableSlices: 'x', }, }; @@ -451,11 +359,8 @@ export const OverlappingGradientBlend: Story = { ], }, ], - enableArea: true, showLegend: true, showGrid: true, - enablePoints: false, - curve: 'monotoneX', xAxisLabel: 'Time', yAxisLabel: 'Value', displayType: 'number', @@ -496,11 +401,8 @@ export const HighContrastOverlap: Story = { ], }, ], - enableArea: true, showLegend: true, showGrid: true, - enablePoints: true, - curve: 'natural', xAxisLabel: 'Day', yAxisLabel: 'Score', displayType: 'number', @@ -529,8 +431,6 @@ export const CurveComparison: Story = { ], showLegend: true, showGrid: true, - enablePoints: true, - curve: 'linear', xAxisLabel: 'X Axis', yAxisLabel: 'Y Axis', displayType: 'number', @@ -584,8 +484,6 @@ export const StepInterpolations: Story = { ], showLegend: true, showGrid: true, - enablePoints: true, - curve: 'step', xAxisLabel: 'Time', yAxisLabel: 'Value', displayType: 'number', @@ -626,11 +524,8 @@ export const NaturalVsMonotone: Story = { ], }, ], - enableArea: true, showLegend: true, showGrid: true, - enablePoints: true, - curve: 'natural', xAxisLabel: 'Sample', yAxisLabel: 'Measurement', displayType: 'number', @@ -684,9 +579,6 @@ export const SliceTooltipDemo: Story = { ], showLegend: true, showGrid: true, - enablePoints: false, - enableSlices: 'x', - xScale: { type: 'point' }, xAxisLabel: 'Month', yAxisLabel: 'Amount ($)', displayType: 'shortNumber', @@ -741,9 +633,6 @@ export const PointTooltipDemo: Story = { ], showLegend: true, showGrid: true, - enablePoints: true, - enableSlices: 'x', - xScale: { type: 'point' }, xAxisLabel: 'Month', yAxisLabel: 'Amount ($)', displayType: 'shortNumber', @@ -751,62 +640,6 @@ export const PointTooltipDemo: Story = { }, }; -export const IntenseOverlapRGB: Story = { - render: renderChart, - args: { - id: 'line-chart-rgb', - data: [ - { - id: 'red', - label: 'Red Channel', - color: 'red', - data: [ - { x: 0, y: 70 }, - { x: 1, y: 85 }, - { x: 2, y: 75 }, - { x: 3, y: 90 }, - { x: 4, y: 80 }, - { x: 5, y: 85 }, - ], - }, - { - id: 'green', - label: 'Green Channel', - color: 'turquoise', - data: [ - { x: 0, y: 65 }, - { x: 1, y: 75 }, - { x: 2, y: 85 }, - { x: 3, y: 80 }, - { x: 4, y: 75 }, - { x: 5, y: 70 }, - ], - }, - { - id: 'blue', - label: 'Blue Channel', - color: 'blue', - data: [ - { x: 0, y: 60 }, - { x: 1, y: 70 }, - { x: 2, y: 80 }, - { x: 3, y: 75 }, - { x: 4, y: 85 }, - { x: 5, y: 80 }, - ], - }, - ], - enableArea: true, - showLegend: true, - showGrid: true, - enablePoints: false, - curve: 'monotoneX', - xAxisLabel: 'Position', - yAxisLabel: 'Intensity', - displayType: 'percentage', - }, -}; - export const Catalog: Story = { render: renderChart, args: { @@ -827,8 +660,6 @@ export const Catalog: Story = { ], showLegend: true, showGrid: true, - enablePoints: false, - enableArea: true, }, decorators: [CatalogDecorator], parameters: { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ChartLegendItemThreshold.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ChartLegendItemThreshold.ts new file mode 100644 index 0000000000..09a9d5df8e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/ChartLegendItemThreshold.ts @@ -0,0 +1 @@ +export const CHART_LEGEND_ITEM_THRESHOLD = 10; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx index a5604fe152..d8920c91e4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphBarChartTooltip.tsx @@ -1,16 +1,14 @@ import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; +import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey'; import { getBarChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartTooltipData'; -import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { getTooltipReferenceFromBarChartElementAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromBarChartElementAnchor'; -import { type BarDatum, type ComputedDatum } from '@nivo/bar'; -import { useMemo } from 'react'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; type GraphBarChartTooltipProps = { - datum: ComputedDatum; - anchorElement: Element; containerId: string; enrichedKeys: BarChartEnrichedKey[]; data: BarChartDataItem[]; @@ -23,8 +21,6 @@ type GraphBarChartTooltipProps = { }; export const GraphBarChartTooltip = ({ - datum, - anchorElement, containerId, enrichedKeys, data, @@ -35,38 +31,38 @@ export const GraphBarChartTooltip = ({ onMouseEnter, onMouseLeave, }: GraphBarChartTooltipProps) => { - const tooltipData = useMemo( - () => - getBarChartTooltipData({ - datum, + const tooltipState = useRecoilComponentValue( + graphWidgetBarTooltipComponentState, + ); + + const tooltipData = !isDefined(tooltipState) + ? null + : getBarChartTooltipData({ + datum: tooltipState.datum, enrichedKeys, data, indexBy, formatOptions, enableGroupTooltip, layout, - }), - [ - datum, - enrichedKeys, - data, - indexBy, - formatOptions, - enableGroupTooltip, - layout, - ], - ); + }); - const { reference, boundary } = useMemo(() => { + let reference = null; + let boundary = null; + + if (isDefined(tooltipState)) { try { - return getTooltipReferenceFromBarChartElementAnchor( - anchorElement, + const positioning = getTooltipReferenceFromBarChartElementAnchor( + tooltipState.anchorElement, containerId, ); + reference = positioning.reference; + boundary = positioning.boundary; } catch { - return { reference: null, boundary: null }; + reference = null; + boundary = null; } - }, [anchorElement, containerId]); + } if ( !isDefined(tooltipData) || diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx index d830c4e4c1..370ead6f88 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart.tsx @@ -9,8 +9,8 @@ import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChar import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; -import { calculateBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange'; import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange'; +import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys'; import { getBarChartAxisConfigs } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisConfigs'; import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor'; import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins'; @@ -33,7 +33,9 @@ import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { useDebouncedCallback } from 'use-debounce'; -import { BAR_CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartLegendItemThreshold'; +import { CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/constants/ChartLegendItemThreshold'; +import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; type GraphWidgetBarChartProps = { data: BarChartDataItem[]; @@ -94,10 +96,9 @@ export const GraphWidgetBarChart = ({ const [chartHeight, setChartHeight] = useState(0); const containerRef = useRef(null); - const [activeBarTooltip, setActiveBarTooltip] = useState<{ - datum: ComputedDatum; - anchorElement: Element; - } | null>(null); + const setActiveBarTooltip = useSetRecoilComponentState( + graphWidgetBarTooltipComponentState, + ); const formatOptions: GraphValueFormatOptions = { displayType, @@ -120,7 +121,7 @@ export const GraphWidgetBarChart = ({ const hasClickableItems = data.some((item) => isDefined(item.to)); - const hideTooltip = useCallback(() => setActiveBarTooltip(null), []); + const hideTooltip = () => setActiveBarTooltip(null); const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300); const handleTooltipMouseEnter = () => { @@ -140,14 +141,14 @@ export const GraphWidgetBarChart = ({ anchorElement: event.currentTarget, }); }, - [debouncedHideTooltip], + [debouncedHideTooltip, setActiveBarTooltip], ); const handleBarLeave = useCallback(() => { debouncedHideTooltip(); }, [debouncedHideTooltip]); - const areThereTooManyKeys = keys.length > BAR_CHART_LEGEND_ITEM_THRESHOLD; + const areThereTooManyKeys = keys.length > CHART_LEGEND_ITEM_THRESHOLD; const shouldShowLegend = showLegend && !areThereTooManyKeys; @@ -199,14 +200,14 @@ export const GraphWidgetBarChart = ({ ); - const calculatedRange = + const calculatedValueRange = groupMode === 'stacked' ? calculateStackedBarChartValueRange(data, keys) - : calculateBarChartValueRange(data, keys); - const effectiveMin = rangeMin ?? calculatedRange.min; - const effectiveMax = rangeMax ?? calculatedRange.max; + : calculateValueRangeFromBarChartKeys(data, keys); + const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum; + const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum; - const hasNegativeValues = calculatedRange.min < 0; + const hasNegativeValues = calculatedValueRange.minimum < 0; const zeroMarker = hasNegativeValues ? [ { @@ -222,8 +223,6 @@ export const GraphWidgetBarChart = ({ const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout }); - const shouldShowBarChartTooltip = isDefined(activeBarTooltip); - return ( - {shouldShowBarChartTooltip && ( - - )} + { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx index 048a4e4976..6cb3a05828 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -1,8 +1,9 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; -import { GraphWidgetBarChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartHasTooManyGroupsEffect'; +import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData'; import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; -import { lazy, Suspense, useMemo } from 'react'; +import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; +import { lazy, Suspense } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type BarChartConfiguration, @@ -48,14 +49,10 @@ export const GraphWidgetBarChartRenderer = ({ hasGroupByOnSecondaryAxis, ); - const filterStateKey = useMemo( - () => - `${configuration.rangeMin ?? ''}-${configuration.rangeMax ?? ''}-${configuration.omitNullValues ?? ''}`, - [ - configuration.rangeMin, - configuration.rangeMax, - configuration.omitNullValues, - ], + const chartFilterKey = generateChartAggregateFilterKey( + configuration.rangeMin, + configuration.rangeMax, + configuration.omitNullValues, ); if (loading) { @@ -64,12 +61,12 @@ export const GraphWidgetBarChartRenderer = ({ return ( <> - }> ; + anchorElement: Element; +} | null>({ + key: 'graphWidgetBarTooltipComponentState', + defaultValue: null, + componentInstanceContext: GraphWidgetComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts index b2e4bfeacb..37b1017ea1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateBarChartValueRange.test.ts @@ -1,8 +1,8 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; -import { calculateBarChartValueRange } from '../calculateBarChartValueRange'; +import { calculateValueRangeFromBarChartKeys } from '../calculateValueRangeFromBarChartKeys'; -describe('calculateBarChartValueRange (essential cases)', () => { - it('returns min=0 and max=highest value for all positive values', () => { +describe('calculateValueRangeFromBarChartKeys (essential cases)', () => { + it('returns minimum=0 and maximum=highest value for all positive values', () => { const data: BarChartDataItem[] = [ { category: 'A', v1: 10, v2: 20 }, { category: 'B', v1: 30, v2: 15 }, @@ -10,13 +10,13 @@ describe('calculateBarChartValueRange (essential cases)', () => { ]; const keys = ['v1', 'v2']; - expect(calculateBarChartValueRange(data, keys)).toEqual({ - min: 0, - max: 40, + expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({ + minimum: 0, + maximum: 40, }); }); - it('returns min=lowest and max=0 for all negative values', () => { + it('returns minimum=lowest and maximum=0 for all negative values', () => { const data: BarChartDataItem[] = [ { category: 'A', v1: -10, v2: -20 }, { category: 'B', v1: -30, v2: -15 }, @@ -24,13 +24,13 @@ describe('calculateBarChartValueRange (essential cases)', () => { ]; const keys = ['v1', 'v2']; - expect(calculateBarChartValueRange(data, keys)).toEqual({ - min: -40, - max: 0, + expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({ + minimum: -40, + maximum: 0, }); }); - it('includes zero and spans min/max when values cross zero', () => { + it('includes zero and spans minimum/maximum when values cross zero', () => { const data: BarChartDataItem[] = [ { category: 'A', v1: -20, v2: 30 }, { category: 'B', v1: 15, v2: -10 }, @@ -38,17 +38,22 @@ describe('calculateBarChartValueRange (essential cases)', () => { ]; const keys = ['v1', 'v2']; - expect(calculateBarChartValueRange(data, keys)).toEqual({ - min: -20, - max: 30, + expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({ + minimum: -20, + maximum: 30, }); }); it('handles empty data and empty keys', () => { - expect(calculateBarChartValueRange([], ['v'])).toEqual({ min: 0, max: 0 }); - expect(calculateBarChartValueRange([{ cat: 'A', v: 10 }], [])).toEqual({ - min: 0, - max: 0, + expect(calculateValueRangeFromBarChartKeys([], ['v'])).toEqual({ + minimum: 0, + maximum: 0, + }); + expect( + calculateValueRangeFromBarChartKeys([{ cat: 'A', v: 10 }], []), + ).toEqual({ + minimum: 0, + maximum: 0, }); }); @@ -60,9 +65,9 @@ describe('calculateBarChartValueRange (essential cases)', () => { ]; const keys = ['v1', 'v2']; - expect(calculateBarChartValueRange(data, keys)).toEqual({ - min: 0, - max: 30, + expect(calculateValueRangeFromBarChartKeys(data, keys)).toEqual({ + minimum: 0, + maximum: 30, }); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts index e7ca93231d..a653f4cb85 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/calculateStackedBarChartValueRange.test.ts @@ -2,7 +2,7 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa import { calculateStackedBarChartValueRange } from '../calculateStackedBarChartValueRange'; describe('calculateStackedBarChartValueRange (essential cases)', () => { - it('returns min=0 and max=largest positive stack', () => { + it('returns minimum=0 and maximum=largest positive stack', () => { const data: BarChartDataItem[] = [ { cat: 'A', v1: 100, v2: 200, v3: 50 }, { cat: 'B', v1: 150, v2: 25, v3: 75 }, @@ -11,12 +11,12 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { const keys = ['v1', 'v2', 'v3']; expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ - min: 0, - max: 350, + minimum: 0, + maximum: 350, }); }); - it('returns min=most negative stack and max=0 for all negative values', () => { + it('returns minimum=most negative stack and maximum=0 for all negative values', () => { const data: BarChartDataItem[] = [ { cat: 'A', v1: -100, v2: -200, v3: 0 }, { cat: 'B', v1: -50, v2: -25, v3: -75 }, @@ -24,8 +24,8 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { const keys = ['v1', 'v2', 'v3']; expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ - min: -300, - max: 0, + minimum: -300, + maximum: 0, }); }); @@ -38,19 +38,19 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { const keys = ['v1', 'v2', 'v3']; expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ - min: -110, - max: 120, + minimum: -110, + maximum: 120, }); }); it('handles empty data and empty keys', () => { expect(calculateStackedBarChartValueRange([], ['v1'])).toEqual({ - min: 0, - max: 0, + minimum: 0, + maximum: 0, }); expect( calculateStackedBarChartValueRange([{ cat: 'A', v1: 10 }], []), - ).toEqual({ min: 0, max: 0 }); + ).toEqual({ minimum: 0, maximum: 0 }); }); it('ignores missing keys and NaN values', () => { @@ -62,8 +62,8 @@ describe('calculateStackedBarChartValueRange (essential cases)', () => { const keys = ['v1', 'v2']; expect(calculateStackedBarChartValueRange(data, keys)).toEqual({ - min: 0, - max: 30, + minimum: 0, + maximum: 30, }); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts deleted file mode 100644 index e4d575156a..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartValueRange.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; - -type ValueRange = { - min: number; - max: number; -}; - -export const calculateBarChartValueRange = ( - data: BarChartDataItem[], - keys: string[], -): ValueRange => { - let min = 0; - let max = 0; - - for (const item of data) { - for (const key of keys) { - const value = Number(item[key] ?? 0); - if (!isNaN(value)) { - if (value < min) { - min = value; - } - if (value > max) { - max = value; - } - } - } - } - - if (min > 0) { - min = 0; - } - if (max < 0) { - max = 0; - } - - return { min, max }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts index c0790a9613..c0469bbcf3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange.ts @@ -1,46 +1,32 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; - -type ValueRange = { - min: number; - max: number; -}; +import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; +import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; export const calculateStackedBarChartValueRange = ( data: BarChartDataItem[], keys: string[], -): ValueRange => { - let min = 0; - let max = 0; +): ChartValueRange => { + const stackedValues: number[] = []; for (const item of data) { - let positiveSum = 0; - let negativeSum = 0; + let positiveSummation = 0; + let negativeSummation = 0; for (const key of keys) { const value = Number(item[key] ?? 0); - if (!Number.isNaN(value)) { - if (value >= 0) { - positiveSum += value; - } else { - negativeSum += value; - } + if (Number.isNaN(value)) { + continue; + } + + if (value >= 0) { + positiveSummation += value; + } else { + negativeSummation += value; } } - if (positiveSum > max) { - max = positiveSum; - } - if (negativeSum < min) { - min = negativeSum; - } + stackedValues.push(positiveSummation, negativeSummation); } - if (min > 0) { - min = 0; - } - if (max < 0) { - max = 0; - } - - return { min, max }; + return calculateValueRangeFromValues(stackedValues); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts new file mode 100644 index 0000000000..dc29862ecb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys.ts @@ -0,0 +1,19 @@ +import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; +import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; +import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; + +export const calculateValueRangeFromBarChartKeys = ( + data: BarChartDataItem[], + keys: string[], +): ChartValueRange => { + const values: number[] = []; + + for (const item of data) { + for (const key of keys) { + const value = Number(item[key] ?? 0); + values.push(value); + } + } + + return calculateValueRangeFromValues(values); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts index 5439574344..e3c15d2220 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartCategoryTickValues.ts @@ -3,6 +3,7 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { computeMinHeightPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeMinHeightPerTick'; import { getBarChartMargins } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartMargins'; +import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues'; export const computeBarChartCategoryTickValues = ({ axisSize, @@ -21,7 +22,11 @@ export const computeBarChartCategoryTickValues = ({ xAxisLabel?: string; yAxisLabel?: string; }): (string | number)[] => { - if (axisSize === 0 || data.length === 0) return []; + if (axisSize === 0 || data.length === 0) { + return []; + } + + const values = data.map((item) => item[indexBy] as string | number); const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout }); @@ -32,22 +37,14 @@ export const computeBarChartCategoryTickValues = ({ const availableAxisSize = axisSize - totalMargins; - const numberOfTicks = Math.floor( - availableAxisSize / - (layout === BarChartLayout.VERTICAL - ? BAR_CHART_MINIMUM_WIDTH_PER_TICK - : computeMinHeightPerTick({ axisFontSize })), - ); + const minimumSizePerTick = + layout === BarChartLayout.VERTICAL + ? BAR_CHART_MINIMUM_WIDTH_PER_TICK + : computeMinHeightPerTick({ axisFontSize }); - if (numberOfTicks <= 0) return []; - if (numberOfTicks === 1) return [data[0][indexBy] as string | number]; - if (numberOfTicks >= data.length) - return data.map((item) => item[indexBy] as string | number); - - const step = (data.length - 1) / (numberOfTicks - 1); - - return Array.from({ length: numberOfTicks }, (_, i) => { - const index = Math.min(Math.round(i * step), data.length - 1); - return data[index][indexBy] as string | number; + return computeChartCategoryTickValues({ + availableSize: availableAxisSize, + minimumSizePerTick, + values, }); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx index 16977a9dd2..b5c82da7c7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/CustomCrosshairLayer.tsx @@ -5,6 +5,8 @@ import { LINE_CHART_CROSSHAIR_TRANSITION_DAMPING } from '@/page-layout/widgets/g import { LINE_CHART_CROSSHAIR_TRANSITION_STIFFNESS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionStiffness'; import { LINE_CHART_MARGIN_LEFT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginLeft'; import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop'; +import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useTheme } from '@emotion/react'; import { type LineSeries, type Point } from '@nivo/line'; import { motion } from 'framer-motion'; @@ -28,7 +30,6 @@ type CustomCrosshairLayerProps = { innerHeight: number; innerWidth: number; onSliceHover: (data: SliceHoverData) => void; - crosshairX: number | null; onRectLeave: (relatedTarget: EventTarget | null) => void; }; @@ -37,10 +38,12 @@ export const CustomCrosshairLayer = ({ innerHeight, innerWidth, onSliceHover, - crosshairX, onRectLeave, }: CustomCrosshairLayerProps) => { const theme = useTheme(); + const crosshairX = useRecoilComponentValue( + graphWidgetLineCrosshairXComponentState, + ); const slices = useMemo(() => { const sliceMap = new Map[]>(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx index c7b4691c01..61d7725564 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphLineChartTooltip.tsx @@ -1,60 +1,61 @@ import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip'; +import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState'; import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries'; import { getLineChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartTooltipData'; -import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; import { getTooltipReferenceFromLineChartPointAnchor } from '@/page-layout/widgets/graph/utils/getTooltipReferenceFromLineChartPointAnchor'; -import { type LineSeries, type SliceTooltipProps } from '@nivo/line'; -import { useMemo } from 'react'; +import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; type GraphLineChartTooltipProps = { - slice: SliceTooltipProps['slice']; - offsetLeft: number; - offsetTop: number; containerId: string; enrichedSeries: LineChartEnrichedSeries[]; formatOptions: GraphValueFormatOptions; - highlightedSeriesId?: string; - linkTo?: string; onMouseEnter?: () => void; onMouseLeave?: () => void; }; export const GraphLineChartTooltip = ({ - slice, - offsetLeft, - offsetTop, containerId, enrichedSeries, formatOptions, - highlightedSeriesId, - linkTo, onMouseEnter, onMouseLeave, }: GraphLineChartTooltipProps) => { - const tooltipData = useMemo( - () => - getLineChartTooltipData({ - slice, + const tooltipState = useRecoilComponentValue( + graphWidgetLineTooltipComponentState, + ); + const tooltipData = !isDefined(tooltipState) + ? null + : getLineChartTooltipData({ + slice: tooltipState.slice, enrichedSeries, formatOptions, - }), - [slice, enrichedSeries, formatOptions], - ); + }); - const { reference, boundary } = useMemo(() => { + let reference = null; + let boundary = null; + + if (isDefined(tooltipState)) { try { - return getTooltipReferenceFromLineChartPointAnchor( + const positioning = getTooltipReferenceFromLineChartPointAnchor( containerId, - offsetLeft, - offsetTop, + tooltipState.offsetLeft, + tooltipState.offsetTop, ); + reference = positioning.reference; + boundary = positioning.boundary; } catch { - return { reference: null, boundary: null }; + reference = null; + boundary = null; } - }, [containerId, offsetLeft, offsetTop]); + } - if (!isDefined(reference) || !isDefined(boundary)) { + if ( + !isDefined(tooltipData) || + !isDefined(reference) || + !isDefined(boundary) + ) { return null; } @@ -64,8 +65,8 @@ export const GraphLineChartTooltip = ({ boundary={boundary} items={tooltipData.items} indexLabel={tooltipData.indexLabel} - highlightedKey={highlightedSeriesId} - linkTo={linkTo} + highlightedKey={tooltipState?.highlightedSeriesId} + linkTo={tooltipState?.linkTo} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} /> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx index 8594057f40..780e635ac6 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart.tsx @@ -1,5 +1,6 @@ import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer'; import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend'; +import { CHART_LEGEND_ITEM_THRESHOLD } from '@/page-layout/widgets/graph/constants/ChartLegendItemThreshold'; import { CustomCrosshairLayer, type SliceHoverData, @@ -11,64 +12,47 @@ import { LINE_CHART_MARGIN_RIGHT } from '@/page-layout/widgets/graph/graphWidget import { LINE_CHART_MARGIN_TOP } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMarginTop'; import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData'; import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme'; +import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState'; +import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState'; import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries'; import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig'; import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig'; import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry'; -import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { + formatGraphValue, + type GraphValueFormatOptions, +} from '@/page-layout/widgets/graph/utils/graphFormatters'; +import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { ResponsiveLine, + type LineCustomSvgLayerProps, type LineSeries, type SliceTooltipProps, } from '@nivo/line'; -import { type ScaleLinearSpec, type ScaleSpec } from '@nivo/scales'; -import { useCallback, useId, useState } from 'react'; -import { isDefined } from 'twenty-shared/utils'; +import { useCallback, useId, useRef, useState } from 'react'; import { useDebouncedCallback } from 'use-debounce'; +type CrosshairLayerProps = LineCustomSvgLayerProps; + type GraphWidgetLineChartProps = { data: LineChartSeries[]; showLegend?: boolean; showGrid?: boolean; - enablePoints?: boolean; + enablePointLabel?: boolean; xAxisLabel?: string; + enableArea?: boolean; yAxisLabel?: string; id: string; - enableArea?: boolean; - stackedArea?: boolean; - curve?: - | 'linear' - | 'monotoneX' - | 'step' - | 'stepBefore' - | 'stepAfter' - | 'natural'; - lineWidth?: number; - enableSlices?: 'x' | 'y'; - xScale?: ScaleSpec; - yScale?: ScaleSpec; + rangeMin?: number; + rangeMax?: number; + omitNullValues?: boolean; + groupMode?: 'stacked'; } & GraphValueFormatOptions; -const getYScaleWithStacking = ( - yScale: ScaleSpec | undefined, - stackedArea: boolean | undefined, -): ScaleSpec => { - if (!yScale || yScale.type === 'linear') { - const linearScale: ScaleLinearSpec = { - min: 0, - max: 'auto', - ...yScale, - type: 'linear', - stacked: stackedArea, - }; - return linearScale; - } - - return yScale; -}; - const StyledContainer = styled.div` align-items: center; display: flex; @@ -82,18 +66,16 @@ export const GraphWidgetLineChart = ({ data, showLegend = true, showGrid = true, - enablePoints = false, + enableArea = true, + enablePointLabel = false, xAxisLabel, yAxisLabel, id, - enableArea = false, - stackedArea = false, - curve = 'monotoneX', - lineWidth = 2, - enableSlices = 'x', - xScale = { type: 'linear' }, - yScale = { type: 'linear', min: 0, max: 'auto' }, + rangeMin, + rangeMax, + omitNullValues: _omitNullValues = false, displayType, + groupMode, decimals, prefix, suffix, @@ -103,6 +85,8 @@ export const GraphWidgetLineChart = ({ const instanceId = useId(); const colorRegistry = createGraphColorRegistry(theme); const chartTheme = useLineChartTheme(); + const containerRef = useRef(null); + const [chartWidth, setChartWidth] = useState(0); const formatOptions: GraphValueFormatOptions = { displayType, @@ -112,6 +96,10 @@ export const GraphWidgetLineChart = ({ customFormatter, }; + const calculatedValueRange = calculateValueRangeFromLineChartSeries(data); + const effectiveMinimumValue = rangeMin ?? calculatedValueRange.minimum; + const effectiveMaximumValue = rangeMax ?? calculatedValueRange.maximum; + const { dataMap, enrichedSeries, @@ -130,19 +118,18 @@ export const GraphWidgetLineChart = ({ theme, }); - const [activeLineTooltip, setActiveLineTooltip] = useState<{ - slice: SliceTooltipProps['slice']; - offsetLeft: number; - offsetTop: number; - highlightedSeriesId: string; - linkTo: string | undefined; - } | null>(null); - const [crosshairX, setCrosshairX] = useState(null); + const setActiveLineTooltip = useSetRecoilComponentState( + graphWidgetLineTooltipComponentState, + ); + + const setCrosshairX = useSetRecoilComponentState( + graphWidgetLineCrosshairXComponentState, + ); const hideTooltip = useCallback(() => { setActiveLineTooltip(null); setCrosshairX(null); - }, []); + }, [setActiveLineTooltip, setCrosshairX]); const debouncedHideTooltip = useDebouncedCallback(hideTooltip, 300); @@ -182,20 +169,42 @@ export const GraphWidgetLineChart = ({ linkTo, }); }, - [dataMap, debouncedHideTooltip], + [dataMap, debouncedHideTooltip, setActiveLineTooltip, setCrosshairX], ); - const axisBottomConfig = getLineChartAxisBottomConfig(xAxisLabel); + const CrosshairLayer = (layerProps: CrosshairLayerProps) => ( + debouncedHideTooltip()} + /> + ); + const axisBottomConfig = getLineChartAxisBottomConfig( + xAxisLabel, + chartWidth, + data, + ); const axisLeftConfig = getLineChartAxisLeftConfig(yAxisLabel, formatOptions); - const shouldShowLineChartTooltip = isDefined(activeLineTooltip); + const areThereTooManySeries = data.length > CHART_LEGEND_ITEM_THRESHOLD; + const shouldShowLegend = showLegend && !areThereTooManySeries; return ( debouncedHideTooltip()} + ref={containerRef} > + { + setChartWidth(width); + }} + /> + formatGraphValue(Number(point.data.y), formatOptions) + } colors={colors} areaBlendMode={'normal'} defs={defs} @@ -223,7 +242,7 @@ export const GraphWidgetLineChart = ({ axisLeft={axisLeftConfig} enableGridX={showGrid} enableGridY={showGrid} - enableSlices={enableSlices} + enableSlices={'x'} sliceTooltip={() => null} tooltip={() => null} layers={[ @@ -232,17 +251,7 @@ export const GraphWidgetLineChart = ({ 'axes', 'areas', 'lines', - (layerProps) => ( - debouncedHideTooltip()} - /> - ), + CrosshairLayer, 'points', 'legends', ]} @@ -251,21 +260,14 @@ export const GraphWidgetLineChart = ({ theme={chartTheme} /> - {shouldShowLineChartTooltip && ( - - )} - + + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx new file mode 100644 index 0000000000..750aec3248 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx @@ -0,0 +1,80 @@ +import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; +import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; +import { LINE_CHART_IS_STACKED_DEFAULT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault'; +import { useGraphLineChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData'; +import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; +import { lazy, Suspense } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { + type LineChartConfiguration, + type PageLayoutWidget, +} from '~/generated/graphql'; + +const GraphWidgetLineChart = lazy(() => + import( + '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart' + ).then((module) => ({ + default: module.GraphWidgetLineChart, + })), +); + +export const GraphWidgetLineChartRenderer = ({ + widget, +}: { + widget: PageLayoutWidget; +}) => { + const { + series, + xAxisLabel, + yAxisLabel, + showDataLabels, + hasTooManyGroups, + loading, + } = useGraphLineChartWidgetData({ + objectMetadataItemId: widget.objectMetadataId, + configuration: widget.configuration as LineChartConfiguration, + }); + + const configuration = widget.configuration as LineChartConfiguration; + + const hasGroupByOnSecondaryAxis = isDefined( + configuration.secondaryAxisGroupByFieldMetadataId, + ); + + const groupMode = + hasGroupByOnSecondaryAxis && + (configuration.isStacked ?? LINE_CHART_IS_STACKED_DEFAULT) + ? 'stacked' + : undefined; + + const chartFilterKey = generateChartAggregateFilterKey( + configuration.rangeMin, + configuration.rangeMax, + configuration.omitNullValues, + ); + + if (loading) { + return ; + } + + return ( + }> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts index 38a94a54ad..c1bd0ae792 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartCrosshairTransitionDamping.ts @@ -1 +1 @@ -export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 20; +export const LINE_CHART_CROSSHAIR_TRANSITION_DAMPING = 30; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault.ts new file mode 100644 index 0000000000..fa50453339 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault.ts @@ -0,0 +1 @@ +export const LINE_CHART_IS_STACKED_DEFAULT = true; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant.ts new file mode 100644 index 0000000000..2a9ef4c600 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant.ts @@ -0,0 +1 @@ +export const LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS = 50; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts new file mode 100644 index 0000000000..18d90a671e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts @@ -0,0 +1,57 @@ +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData'; +import { useMemo } from 'react'; +import { type LineChartConfiguration } from '~/generated/graphql'; + +type UseGraphLineChartWidgetDataProps = { + objectMetadataItemId: string; + configuration: LineChartConfiguration; +}; + +type UseGraphLineChartWidgetDataResult = { + series: LineChartSeries[]; + xAxisLabel?: string; + yAxisLabel?: string; + showDataLabels: boolean; + hasTooManyGroups: boolean; + loading: boolean; + error?: Error; +}; + +export const useGraphLineChartWidgetData = ({ + objectMetadataItemId, + configuration, +}: UseGraphLineChartWidgetDataProps): UseGraphLineChartWidgetDataResult => { + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: objectMetadataItemId, + }); + + const { + data: groupByData, + loading, + error, + aggregateOperation, + } = useGraphWidgetGroupByQuery({ + objectMetadataItemId, + configuration, + }); + + const transformedData = useMemo( + () => + transformGroupByDataToLineChartData({ + groupByData, + objectMetadataItem, + configuration, + aggregateOperation, + }), + [groupByData, objectMetadataItem, configuration, aggregateOperation], + ); + + return { + ...transformedData, + loading, + error, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState.ts new file mode 100644 index 0000000000..e2d5c3da61 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState.ts @@ -0,0 +1,10 @@ +import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const graphWidgetLineCrosshairXComponentState = createComponentState< + number | null +>({ + key: 'graphWidgetLineCrosshairXComponentState', + defaultValue: null, + componentInstanceContext: GraphWidgetComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState.ts new file mode 100644 index 0000000000..3a0b28e1e2 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState.ts @@ -0,0 +1,15 @@ +import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { type LineSeries, type SliceTooltipProps } from '@nivo/line'; + +export const graphWidgetLineTooltipComponentState = createComponentState<{ + slice: SliceTooltipProps['slice']; + offsetLeft: number; + offsetTop: number; + highlightedSeriesId: string; + linkTo: string | undefined; +} | null>({ + key: 'graphWidgetLineTooltipComponentState', + defaultValue: null, + componentInstanceContext: GraphWidgetComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts new file mode 100644 index 0000000000..ab16f1a927 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries.ts @@ -0,0 +1,18 @@ +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues'; +import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; + +export const calculateValueRangeFromLineChartSeries = ( + data: LineChartSeries[], +): ChartValueRange => { + const values: number[] = []; + + for (const series of data) { + for (const point of series.data) { + const value = Number(point.y ?? 0); + values.push(value); + } + } + + return calculateValueRangeFromValues(values); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts new file mode 100644 index 0000000000..a7587b9f01 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues.ts @@ -0,0 +1,34 @@ +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues'; + +const LINE_CHART_MARGIN_LEFT = 70; +const LINE_CHART_MARGIN_RIGHT = 20; +const LINE_CHART_MINIMUM_WIDTH_PER_TICK = 100; + +export const computeLineChartCategoryTickValues = ({ + width, + data, +}: { + width: number; + data: LineChartSeries[]; +}): (string | number)[] => { + if (data.length === 0 || data[0].data.length === 0) { + return []; + } + + const values = data[0].data.map((point) => { + const value = point.x; + return typeof value === 'number' || typeof value === 'string' + ? value + : String(value); + }); + + const availableWidth = + width - (LINE_CHART_MARGIN_LEFT + LINE_CHART_MARGIN_RIGHT); + + return computeChartCategoryTickValues({ + availableSize: availableWidth, + minimumSizePerTick: LINE_CHART_MINIMUM_WIDTH_PER_TICK, + values, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts index d63df65584..4c11ae7bbe 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig.ts @@ -1,8 +1,23 @@ -export const getLineChartAxisBottomConfig = (xAxisLabel?: string) => ({ - tickSize: 0, - tickPadding: 5, - tickRotation: 0, - legend: xAxisLabel, - legendPosition: 'middle' as const, - legendOffset: 40, -}); +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { computeLineChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues'; + +export const getLineChartAxisBottomConfig = ( + xAxisLabel?: string, + width?: number, + data?: LineChartSeries[], +) => { + const tickValues = + width && data + ? computeLineChartCategoryTickValues({ width, data }) + : undefined; + + return { + tickSize: 0, + tickPadding: 5, + tickRotation: 0, + tickValues, + legend: xAxisLabel, + legendPosition: 'middle' as const, + legendOffset: 40, + }; +}; 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 index 86c1edec53..8b2bcdfb82 100644 --- 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 @@ -1,19 +1,19 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields'; import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon'; +import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration'; import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery'; -import { generateGroupByQueryVariablesFromBarChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration'; +import { generateGroupByQueryVariablesFromChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration'; 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; + configuration: GroupByChartConfiguration; }) => { const { objectMetadataItem, aggregateField, gqlOperationFilter } = useGraphWidgetQueryCommon({ @@ -43,9 +43,9 @@ export const useGraphWidgetGroupByQuery = ({ } const groupByQueryVariables = - generateGroupByQueryVariablesFromBarChartConfiguration({ + generateGroupByQueryVariablesFromChartConfiguration({ objectMetadataItem, - barChartConfiguration: configuration, + chartConfiguration: configuration, aggregateOperation: aggregateOperation, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts index c1f85fc5c3..5dfcfb3c12 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts @@ -6,6 +6,7 @@ import { import { type AggregateChartConfiguration, type BarChartConfiguration, + type LineChartConfiguration, } from '~/generated/graphql'; export const useGraphWidgetQueryCommon = ({ @@ -13,7 +14,10 @@ export const useGraphWidgetQueryCommon = ({ configuration, }: { objectMetadataItemId: string; - configuration: BarChartConfiguration | AggregateChartConfiguration; + configuration: + | BarChartConfiguration + | AggregateChartConfiguration + | LineChartConfiguration; }) => { const { objectMetadataItem } = useObjectMetadataItemById({ objectId: objectMetadataItemId, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartValueRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartValueRange.ts new file mode 100644 index 0000000000..5d8249555d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/ChartValueRange.ts @@ -0,0 +1,4 @@ +export type ChartValueRange = { + minimum: number; + maximum: number; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts new file mode 100644 index 0000000000..6da4bf5a71 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/GroupByChartConfiguration.ts @@ -0,0 +1,8 @@ +import { + type BarChartConfiguration, + type LineChartConfiguration, +} from '~/generated-metadata/graphql'; + +export type GroupByChartConfiguration = + | BarChartConfiguration + | LineChartConfiguration; 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 deleted file mode 100644 index 0aa4515a47..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with secondary axis 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with composite field 1`] = ` -{ - "groupBy": [ - { - "name": { - "firstName": true, - }, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], -} -`; - -exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with two groupBy fields 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - { - "ownerId": true, - }, - ], -} -`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromChartConfiguration.test.ts.snap new file mode 100644 index 0000000000..62a5bac6ae --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromChartConfiguration.test.ts.snap @@ -0,0 +1,131 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with secondary axis 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + { + "ownerId": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Horizontal bar configuration should generate variables with single groupBy field 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with composite field 1`] = ` +{ + "groupBy": [ + { + "name": { + "firstName": true, + }, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with date field and granularity 1`] = ` +{ + "groupBy": [ + { + "createdAt": { + "granularity": "MONTH", + }, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with single groupBy field 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Bar Chart Configuration Vertical bar configuration should generate variables with two groupBy fields 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + { + "ownerId": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with composite field 1`] = ` +{ + "groupBy": [ + { + "name": { + "firstName": true, + }, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with date field and granularity 1`] = ` +{ + "groupBy": [ + { + "createdAt": { + "granularity": "MONTH", + }, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with secondary axis 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + { + "ownerId": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with single groupBy field 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + ], +} +`; + +exports[`generateGroupByQueryVariablesFromChartConfiguration Line Chart Configuration should generate variables with two groupBy fields (multi-series) 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + { + "ownerId": true, + }, + ], +} +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateValueRangeFromValues.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateValueRangeFromValues.test.ts new file mode 100644 index 0000000000..838efe7516 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateValueRangeFromValues.test.ts @@ -0,0 +1,75 @@ +import { calculateValueRangeFromValues } from '../calculateValueRangeFromValues'; + +describe('calculateValueRangeFromValues', () => { + it('should return minimum=0 and maximum=highest value for all positive values', () => { + const values = [5, 10, 15, 20]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 20 }); + }); + + it('should return minimum=lowest value and maximum=0 for all negative values', () => { + const values = [-5, -10, -15, -20]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: -20, maximum: 0 }); + }); + + it('should include zero in range when values cross zero', () => { + const values = [-10, -5, 5, 10]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: -10, maximum: 10 }); + }); + + it('should handle empty array', () => { + const values: number[] = []; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 0 }); + }); + + it('should ignore NaN values', () => { + const values = [5, NaN, 10, NaN, 15]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 15 }); + }); + + it('should handle all NaN values', () => { + const values = [NaN, NaN, NaN]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 0 }); + }); + + it('should handle single value', () => { + const values = [42]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 42 }); + }); + + it('should handle single negative value', () => { + const values = [-42]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: -42, maximum: 0 }); + }); + + it('should handle zero value', () => { + const values = [0]; + + const result = calculateValueRangeFromValues(values); + + expect(result).toEqual({ minimum: 0, maximum: 0 }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartCategoryTickValues.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartCategoryTickValues.test.ts new file mode 100644 index 0000000000..c67bc458cb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/computeChartCategoryTickValues.test.ts @@ -0,0 +1,92 @@ +import { computeChartCategoryTickValues } from '../computeChartCategoryTickValues'; + +describe('computeChartCategoryTickValues', () => { + it('should return all values when available size allows', () => { + const values = ['A', 'B', 'C', 'D', 'E']; + const result = computeChartCategoryTickValues({ + availableSize: 500, + minimumSizePerTick: 100, + values, + }); + + expect(result).toEqual(['A', 'B', 'C', 'D', 'E']); + }); + + it('should return subset of values when space is limited', () => { + const values = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const result = computeChartCategoryTickValues({ + availableSize: 400, + minimumSizePerTick: 100, + values, + }); + + expect(result.length).toBe(4); + expect(result).toEqual(['A', 'C', 'F', 'H']); + }); + + it('should return empty array when available size is zero', () => { + const values = ['A', 'B', 'C']; + const result = computeChartCategoryTickValues({ + availableSize: 0, + minimumSizePerTick: 100, + values, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when available size is negative', () => { + const values = ['A', 'B', 'C']; + const result = computeChartCategoryTickValues({ + availableSize: -100, + minimumSizePerTick: 100, + values, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when values array is empty', () => { + const result = computeChartCategoryTickValues({ + availableSize: 500, + minimumSizePerTick: 100, + values: [], + }); + + expect(result).toEqual([]); + }); + + it('should handle single value', () => { + const values = ['A']; + const result = computeChartCategoryTickValues({ + availableSize: 500, + minimumSizePerTick: 100, + values, + }); + + expect(result).toEqual(['A']); + }); + + it('should work with numeric values', () => { + const values = [1, 2, 3, 4, 5, 6, 7, 8]; + const result = computeChartCategoryTickValues({ + availableSize: 300, + minimumSizePerTick: 100, + values, + }); + + expect(result.length).toBe(3); + expect(result).toEqual([1, 5, 8]); + }); + + it('should handle very small available size', () => { + const values = ['A', 'B', 'C', 'D', 'E']; + const result = computeChartCategoryTickValues({ + availableSize: 50, + minimumSizePerTick: 100, + values, + }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateChartAggregateFilterKey.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateChartAggregateFilterKey.test.ts new file mode 100644 index 0000000000..d12d5ae0a7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateChartAggregateFilterKey.test.ts @@ -0,0 +1,55 @@ +import { generateChartAggregateFilterKey } from '../generateChartAggregateFilterKey'; + +describe('generateChartAggregateFilterKey', () => { + it('should generate key with all values provided', () => { + const result = generateChartAggregateFilterKey(0, 100, true); + + expect(result).toBe('0-100-true'); + }); + + it('should generate key with negative values', () => { + const result = generateChartAggregateFilterKey(-50, 50, false); + + expect(result).toBe('-50-50-false'); + }); + + it('should handle undefined values as empty strings', () => { + const result = generateChartAggregateFilterKey( + undefined, + undefined, + undefined, + ); + + expect(result).toBe('--'); + }); + + it('should handle null values as empty strings', () => { + const result = generateChartAggregateFilterKey(null, null, null); + + expect(result).toBe('--'); + }); + + it('should handle mixed defined and undefined values', () => { + const result = generateChartAggregateFilterKey(10, undefined, true); + + expect(result).toBe('10--true'); + }); + + it('should handle zero as valid value', () => { + const result = generateChartAggregateFilterKey(0, 0, false); + + expect(result).toBe('0-0-false'); + }); + + it('should handle no arguments', () => { + const result = generateChartAggregateFilterKey(); + + expect(result).toBe('--'); + }); + + it('should handle decimal values', () => { + const result = generateChartAggregateFilterKey(10.5, 99.9, true); + + expect(result).toBe('10.5-99.9-true'); + }); +}); 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 deleted file mode 100644 index 297048389c..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarChartConfiguration.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { - AggregateOperations, - type BarChartConfiguration, - FieldMetadataType, - GraphType, -} from '~/generated-metadata/graphql'; -import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration'; - -describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => { - const mockObjectMetadataItem: ObjectMetadataItem = { - id: 'obj-1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - fields: [ - { - id: 'field-1', - name: 'stage', - type: FieldMetadataType.TEXT, - }, - { - id: 'field-2', - name: 'owner', - type: FieldMetadataType.RELATION, - }, - { - id: 'field-3', - name: 'createdAt', - type: FieldMetadataType.DATE_TIME, - }, - { - id: 'field-4', - name: 'name', - type: FieldMetadataType.FULL_NAME, - }, - ], - } as ObjectMetadataItem; - - const buildConfiguration = ( - overrides: Partial, - ): BarChartConfiguration => - ({ - __typename: 'BarChartConfiguration', - aggregateFieldMetadataId: 'aggregate-field', - aggregateOperation: AggregateOperations.COUNT, - graphType: GraphType.VERTICAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-1', - ...overrides, - }) as BarChartConfiguration; - - describe('Vertical bar configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - graphType: GraphType.VERTICAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with two groupBy fields', () => { - const result = generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - graphType: GraphType.VERTICAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - secondaryAxisGroupByFieldMetadataId: 'field-2', - secondaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with composite field', () => { - const result = generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - graphType: GraphType.VERTICAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-4', - primaryAxisGroupBySubFieldName: 'firstName', - }), - }); - - expect(result).toMatchSnapshot(); - }); - }); - - describe('Horizontal bar configuration', () => { - it('should generate variables with single groupBy field', () => { - const result = generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - graphType: GraphType.HORIZONTAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-1', - primaryAxisGroupBySubFieldName: null, - }), - }); - - expect(result).toMatchSnapshot(); - }); - - it('should generate variables with secondary axis', () => { - const result = generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - graphType: GraphType.HORIZONTAL_BAR, - primaryAxisGroupByFieldMetadataId: 'field-1', - secondaryAxisGroupByFieldMetadataId: 'field-2', - }), - }); - - expect(result).toMatchSnapshot(); - }); - }); - - it('should throw error when primary axis field not found', () => { - expect(() => - generateGroupByQueryVariablesFromBarChartConfiguration({ - objectMetadataItem: mockObjectMetadataItem, - barChartConfiguration: buildConfiguration({ - primaryAxisGroupByFieldMetadataId: 'invalid-field', - }), - }), - ).toThrow('Field with id invalid-field not found in object metadata'); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromChartConfiguration.test.ts new file mode 100644 index 0000000000..16b8fc0222 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromChartConfiguration.test.ts @@ -0,0 +1,244 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + AggregateOperations, + type BarChartConfiguration, + FieldMetadataType, + GraphType, + type LineChartConfiguration, +} from '~/generated-metadata/graphql'; +import { generateGroupByQueryVariablesFromChartConfiguration } from '../generateGroupByQueryVariablesFromChartConfiguration'; + +describe('generateGroupByQueryVariablesFromChartConfiguration', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + id: 'field-1', + name: 'stage', + type: FieldMetadataType.TEXT, + }, + { + id: 'field-2', + name: 'owner', + type: FieldMetadataType.RELATION, + }, + { + id: 'field-3', + name: 'createdAt', + type: FieldMetadataType.DATE_TIME, + }, + { + id: 'field-4', + name: 'name', + type: FieldMetadataType.FULL_NAME, + }, + ], + } as ObjectMetadataItem; + + const buildBarChartConfiguration = ( + overrides: Partial, + ): BarChartConfiguration => + ({ + __typename: 'BarChartConfiguration', + aggregateFieldMetadataId: 'aggregate-field', + aggregateOperation: AggregateOperations.COUNT, + graphType: GraphType.VERTICAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-1', + ...overrides, + }) as BarChartConfiguration; + + const buildLineChartConfiguration = ( + overrides: Partial, + ): LineChartConfiguration => + ({ + __typename: 'LineChartConfiguration', + aggregateFieldMetadataId: 'aggregate-field', + aggregateOperation: AggregateOperations.COUNT, + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-1', + ...overrides, + }) as LineChartConfiguration; + + describe('Bar Chart Configuration', () => { + describe('Vertical bar configuration', () => { + it('should generate variables with single groupBy field', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.VERTICAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-1', + primaryAxisGroupBySubFieldName: null, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with two groupBy fields', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.VERTICAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-1', + primaryAxisGroupBySubFieldName: null, + secondaryAxisGroupByFieldMetadataId: 'field-2', + secondaryAxisGroupBySubFieldName: null, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with composite field', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.VERTICAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-4', + primaryAxisGroupBySubFieldName: 'firstName', + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with date field and granularity', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.VERTICAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-3', + primaryAxisGroupBySubFieldName: null, + primaryAxisDateGranularity: 'MONTH' as any, + }), + }); + + expect(result).toMatchSnapshot(); + }); + }); + + describe('Horizontal bar configuration', () => { + it('should generate variables with single groupBy field', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.HORIZONTAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-1', + primaryAxisGroupBySubFieldName: null, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with secondary axis', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + graphType: GraphType.HORIZONTAL_BAR, + primaryAxisGroupByFieldMetadataId: 'field-1', + secondaryAxisGroupByFieldMetadataId: 'field-2', + }), + }); + + expect(result).toMatchSnapshot(); + }); + }); + }); + + describe('Line Chart Configuration', () => { + it('should generate variables with single groupBy field', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-1', + primaryAxisGroupBySubFieldName: null, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with two groupBy fields (multi-series)', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-1', + primaryAxisGroupBySubFieldName: null, + secondaryAxisGroupByFieldMetadataId: 'field-2', + secondaryAxisGroupBySubFieldName: null, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with date field and granularity', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-3', + primaryAxisGroupBySubFieldName: null, + primaryAxisDateGranularity: 'MONTH' as any, + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with composite field', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-4', + primaryAxisGroupBySubFieldName: 'firstName', + }), + }); + + expect(result).toMatchSnapshot(); + }); + + it('should generate variables with secondary axis', () => { + const result = generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + graphType: GraphType.LINE, + primaryAxisGroupByFieldMetadataId: 'field-1', + secondaryAxisGroupByFieldMetadataId: 'field-2', + }), + }); + + expect(result).toMatchSnapshot(); + }); + }); + + describe('Error handling', () => { + it('should throw error when primary axis field not found (bar chart)', () => { + expect(() => + generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildBarChartConfiguration({ + primaryAxisGroupByFieldMetadataId: 'invalid-field', + }), + }), + ).toThrow('Field with id invalid-field not found in object metadata'); + }); + + it('should throw error when primary axis field not found (line chart)', () => { + expect(() => + generateGroupByQueryVariablesFromChartConfiguration({ + objectMetadataItem: mockObjectMetadataItem, + chartConfiguration: buildLineChartConfiguration({ + primaryAxisGroupByFieldMetadataId: 'invalid-field', + }), + }), + ).toThrow('Field with id invalid-field not found in object metadata'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts new file mode 100644 index 0000000000..97411526c3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts @@ -0,0 +1,234 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { + AggregateOperations, + FieldMetadataType, + GraphType, + type LineChartConfiguration, +} from '~/generated-metadata/graphql'; +import { transformOneDimensionalGroupByToLineChartData } from '../transformOneDimensionalGroupByToLineChartData'; + +describe('transformOneDimensionalGroupByToLineChartData', () => { + const mockAggregateField: FieldMetadataItem = { + id: 'amount-field', + name: 'amount', + label: 'Amount', + type: FieldMetadataType.NUMBER, + } as FieldMetadataItem; + + const mockGroupByFieldX: FieldMetadataItem = { + id: 'stage-field', + name: 'stage', + label: 'Stage', + type: FieldMetadataType.TEXT, + } as FieldMetadataItem; + + const mockDateGroupByField: FieldMetadataItem = { + id: 'created-at-field', + name: 'createdAt', + label: 'Created At', + type: FieldMetadataType.DATE_TIME, + } as FieldMetadataItem; + + const mockObjectMetadataItem = { + id: 'opportunity-object', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [mockAggregateField, mockGroupByFieldX, mockDateGroupByField], + } as ObjectMetadataItem; + + const buildConfiguration = ( + overrides: Partial = {}, + ): LineChartConfiguration => + ({ + __typename: 'LineChartConfiguration', + graphType: GraphType.LINE, + aggregateFieldMetadataId: 'amount-field', + aggregateOperation: AggregateOperations.SUM, + primaryAxisGroupByFieldMetadataId: 'stage-field', + color: 'blue', + ...overrides, + }) as LineChartConfiguration; + + describe('Categorical X-axis', () => { + it('should transform simple categorical groupBy results', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['Qualification'], + sumAmount: 150000, + }, + { + groupByDimensionValues: ['Proposal'], + sumAmount: 280000, + }, + { + groupByDimensionValues: ['Closed Won'], + sumAmount: 450000, + }, + ]; + + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toHaveLength(1); + expect(result.series[0]).toMatchObject({ + id: 'amount', + label: 'Amount', + color: 'blue', + }); + expect(result.series[0].data).toEqual([ + { x: 'Qualification', y: 150000 }, + { x: 'Proposal', y: 280000 }, + { x: 'Closed Won', y: 450000 }, + ]); + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should filter out null aggregate values', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['Stage A'], + sumAmount: 100, + }, + { + groupByDimensionValues: ['Stage B'], + sumAmount: null, + }, + { + groupByDimensionValues: ['Stage C'], + sumAmount: 200, + }, + ]; + + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series[0].data).toEqual([ + { x: 'Stage A', y: 100 }, + { x: 'Stage C', y: 200 }, + ]); + expect(result.hasTooManyGroups).toBe(false); + }); + }); + + describe('Time-series X-axis', () => { + it('should transform date-based groupBy results', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01'], + sumAmount: 50000, + }, + { + groupByDimensionValues: ['2024-02'], + sumAmount: 75000, + }, + { + groupByDimensionValues: ['2024-03'], + sumAmount: 60000, + }, + ]; + + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockDateGroupByField, + aggregateField: mockAggregateField, + configuration: buildConfiguration({ + primaryAxisGroupByFieldMetadataId: 'created-at-field', + primaryAxisDateGranularity: 'MONTH' as any, + }), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toHaveLength(1); + expect(result.series[0].data).toHaveLength(3); + expect(result.series[0].data[0]).toHaveProperty('x'); + expect(result.series[0].data[0]).toHaveProperty('y', 50000); + }); + }); + + describe('Edge cases', () => { + it('should handle empty results', () => { + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults: [], + groupByFieldX: mockGroupByFieldX, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toHaveLength(1); + expect(result.series[0].data).toEqual([]); + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should use default color when not specified', () => { + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults: [ + { + groupByDimensionValues: ['Test'], + sumAmount: 100, + }, + ], + groupByFieldX: mockGroupByFieldX, + aggregateField: mockAggregateField, + configuration: buildConfiguration({ color: undefined }), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series[0].color).toBeDefined(); + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should handle COUNT operation', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['Stage A'], + _count: 5, + }, + { + groupByDimensionValues: ['Stage B'], + _count: 10, + }, + ]; + + const result = transformOneDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + aggregateField: mockAggregateField, + configuration: buildConfiguration({ + aggregateOperation: AggregateOperations.COUNT, + }), + aggregateOperation: '_count', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series[0].data).toEqual([ + { x: 'Stage A', y: 5 }, + { x: 'Stage B', y: 10 }, + ]); + expect(result.hasTooManyGroups).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts new file mode 100644 index 0000000000..26cffd9a08 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts @@ -0,0 +1,310 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { + AggregateOperations, + FieldMetadataType, + GraphType, + type LineChartConfiguration, +} from '~/generated-metadata/graphql'; +import { transformTwoDimensionalGroupByToLineChartData } from '../transformTwoDimensionalGroupByToLineChartData'; + +describe('transformTwoDimensionalGroupByToLineChartData', () => { + const mockAggregateField: FieldMetadataItem = { + id: 'amount-field', + name: 'amount', + label: 'Amount', + type: FieldMetadataType.NUMBER, + } as FieldMetadataItem; + + const mockGroupByFieldX: FieldMetadataItem = { + id: 'created-at-field', + name: 'createdAt', + label: 'Created At', + type: FieldMetadataType.DATE_TIME, + } as FieldMetadataItem; + + const mockGroupByFieldY: FieldMetadataItem = { + id: 'stage-field', + name: 'stage', + label: 'Stage', + type: FieldMetadataType.TEXT, + } as FieldMetadataItem; + + const mockObjectMetadataItem = { + id: 'opportunity-object', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [mockAggregateField, mockGroupByFieldX, mockGroupByFieldY], + } as ObjectMetadataItem; + + const buildConfiguration = ( + overrides: Partial = {}, + ): LineChartConfiguration => + ({ + __typename: 'LineChartConfiguration', + graphType: GraphType.LINE, + aggregateFieldMetadataId: 'amount-field', + aggregateOperation: AggregateOperations.SUM, + primaryAxisGroupByFieldMetadataId: 'created-at-field', + secondaryAxisGroupByFieldMetadataId: 'stage-field', + color: 'blue', + ...overrides, + }) as LineChartConfiguration; + + describe('Multi-series transformation', () => { + it('should create multiple series from 2D groupBy results', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01', 'Qualification'], + sumAmount: 50000, + }, + { + groupByDimensionValues: ['2024-01', 'Proposal'], + sumAmount: 75000, + }, + { + groupByDimensionValues: ['2024-02', 'Qualification'], + sumAmount: 60000, + }, + { + groupByDimensionValues: ['2024-02', 'Proposal'], + sumAmount: 90000, + }, + { + groupByDimensionValues: ['2024-03', 'Qualification'], + sumAmount: 55000, + }, + { + groupByDimensionValues: ['2024-03', 'Proposal'], + sumAmount: 80000, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toHaveLength(2); + + expect(result.series[0]).toMatchObject({ + id: expect.any(String), + label: expect.any(String), + color: 'blue', + }); + + expect(result.series[0].data).toHaveLength(3); + expect(result.series[1].data).toHaveLength(3); + + expect(result.series[0].data[0]).toHaveProperty('x'); + expect(result.series[0].data[0]).toHaveProperty('y'); + + result.series.forEach((series) => { + series.data.forEach((point) => { + expect(point).toHaveProperty('x'); + expect(point).toHaveProperty('y'); + }); + }); + + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should preserve backend ordering of data points', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01', 'Stage A'], + sumAmount: 100, + }, + { + groupByDimensionValues: ['2024-02', 'Stage A'], + sumAmount: 200, + }, + { + groupByDimensionValues: ['2024-03', 'Stage A'], + sumAmount: 300, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + const series = result.series[0]; + expect(series.data[0].y).toBe(100); + expect(series.data[1].y).toBe(200); + expect(series.data[2].y).toBe(300); + + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should normalize sparse data (all series share same X values, with 0 for missing)', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01', 'Stage A'], + sumAmount: 100, + }, + { + groupByDimensionValues: ['2024-02', 'Stage A'], + sumAmount: 200, + }, + { + groupByDimensionValues: ['2024-01', 'Stage B'], + sumAmount: 150, + }, + { + groupByDimensionValues: ['2024-03', 'Stage B'], + sumAmount: 250, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + const stageA = result.series.find((s) => s.id === 'Stage A'); + expect(stageA?.data).toHaveLength(3); + expect(stageA?.data[0].y).toBe(100); + expect(stageA?.data[1].y).toBe(200); + expect(stageA?.data[2].y).toBe(0); + + const stageB = result.series.find((s) => s.id === 'Stage B'); + expect(stageB?.data).toHaveLength(3); + expect(stageB?.data[0].y).toBe(150); + expect(stageB?.data[1].y).toBe(0); + expect(stageB?.data[2].y).toBe(250); + + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should filter out null aggregate values', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01', 'Stage A'], + sumAmount: 100, + }, + { + groupByDimensionValues: ['2024-02', 'Stage A'], + sumAmount: null, + }, + { + groupByDimensionValues: ['2024-03', 'Stage A'], + sumAmount: 200, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series[0].data).toHaveLength(2); + expect(result.series[0].data.map((d) => d.y)).toEqual([100, 200]); + + expect(result.hasTooManyGroups).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should handle empty results', () => { + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults: [], + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toEqual([]); + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should skip results with missing dimension values', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01'], + sumAmount: 100, + }, + { + groupByDimensionValues: ['2024-02', 'Stage A'], + sumAmount: 200, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration(), + aggregateOperation: 'sumAmount', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series).toHaveLength(1); + expect(result.series[0].data).toHaveLength(1); + + expect(result.hasTooManyGroups).toBe(false); + }); + + it('should handle COUNT operation', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01', 'Stage A'], + _count: 5, + }, + { + groupByDimensionValues: ['2024-02', 'Stage A'], + _count: 10, + }, + ]; + + const result = transformTwoDimensionalGroupByToLineChartData({ + rawResults, + groupByFieldX: mockGroupByFieldX, + groupByFieldY: mockGroupByFieldY, + aggregateField: mockAggregateField, + configuration: buildConfiguration({ + aggregateOperation: AggregateOperations.COUNT, + }), + aggregateOperation: '_count', + objectMetadataItem: mockObjectMetadataItem, + primaryAxisSubFieldName: null, + }); + + expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]); + + expect(result.hasTooManyGroups).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateValueRangeFromValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateValueRangeFromValues.ts new file mode 100644 index 0000000000..3bd9e6d4d0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateValueRangeFromValues.ts @@ -0,0 +1,22 @@ +import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange'; + +export const calculateValueRangeFromValues = ( + values: number[], +): ChartValueRange => { + let minimumValue = 0; + let maximumValue = 0; + + for (const value of values) { + if (isNaN(value)) { + continue; + } + + minimumValue = Math.min(minimumValue, value); + maximumValue = Math.max(maximumValue, value); + } + + minimumValue = Math.min(minimumValue, 0); + maximumValue = Math.max(maximumValue, 0); + + return { minimum: minimumValue, maximum: maximumValue }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeCategoryTickValues.ts new file mode 100644 index 0000000000..931061c880 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeCategoryTickValues.ts @@ -0,0 +1,16 @@ +export const computeCategoryTickValues = ( + numberOfTicks: number, + totalValues: number, +): number[] => { + if (numberOfTicks <= 0) return []; + if (numberOfTicks === 1) return [0]; + if (numberOfTicks >= totalValues) + return Array.from({ length: totalValues }, (_, i) => i); + + const step = (totalValues - 1) / (numberOfTicks - 1); + + return Array.from({ length: numberOfTicks }, (_, i) => { + const index = Math.min(Math.round(i * step), totalValues - 1); + return index; + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartCategoryTickValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartCategoryTickValues.ts new file mode 100644 index 0000000000..8da2a8830b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/computeChartCategoryTickValues.ts @@ -0,0 +1,20 @@ +import { computeCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeCategoryTickValues'; + +export const computeChartCategoryTickValues = ({ + availableSize, + minimumSizePerTick, + values, +}: { + availableSize: number; + minimumSizePerTick: number; + values: (string | number)[]; +}): (string | number)[] => { + if (availableSize <= 0 || values.length === 0) { + return []; + } + + const numberOfTicks = Math.floor(availableSize / minimumSizePerTick); + const tickIndices = computeCategoryTickValues(numberOfTicks, values.length); + + return tickIndices.map((index) => values[index]); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateChartAggregateFilterKey.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateChartAggregateFilterKey.ts new file mode 100644 index 0000000000..7ead0a93e0 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateChartAggregateFilterKey.ts @@ -0,0 +1,7 @@ +export const generateChartAggregateFilterKey = ( + rangeMin?: number | null, + rangeMax?: number | null, + omitNullValues?: boolean | null, +): string => { + return `${rangeMin ?? ''}-${rangeMax ?? ''}-${omitNullValues ?? ''}`; +}; 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/generateGroupByQueryVariablesFromChartConfiguration.ts similarity index 64% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts index c0153f55cb..4f3658bf3d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts @@ -1,4 +1,5 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration'; import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy'; import { type AggregateOrderByWithGroupByField, @@ -7,29 +8,27 @@ import { type ObjectRecordOrderByWithGroupByDateField, } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { type BarChartConfiguration } from '~/generated/graphql'; import { buildGroupByFieldObject } from './buildGroupByFieldObject'; -export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ +export const generateGroupByQueryVariablesFromChartConfiguration = ({ objectMetadataItem, - barChartConfiguration, + chartConfiguration, aggregateOperation, }: { objectMetadataItem: ObjectMetadataItem; - barChartConfiguration: BarChartConfiguration; + chartConfiguration: GroupByChartConfiguration; aggregateOperation?: string; }) => { - const groupByFieldXId = - barChartConfiguration.primaryAxisGroupByFieldMetadataId; + const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId; const groupByFieldYId = - barChartConfiguration.secondaryAxisGroupByFieldMetadataId; + chartConfiguration.secondaryAxisGroupByFieldMetadataId; const groupBySubFieldNameX = - barChartConfiguration.primaryAxisGroupBySubFieldName ?? undefined; + chartConfiguration.primaryAxisGroupBySubFieldName ?? undefined; const groupBySubFieldNameY = - barChartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined; + chartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined; const groupByFieldX = objectMetadataItem.fields.find( (field) => field.id === groupByFieldXId, @@ -54,7 +53,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ field: groupByFieldX, subFieldName: groupBySubFieldNameX, dateGranularity: - barChartConfiguration.primaryAxisDateGranularity ?? undefined, + chartConfiguration.primaryAxisDateGranularity ?? undefined, }), ); @@ -64,8 +63,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ field: groupByFieldY, subFieldName: groupBySubFieldNameY, dateGranularity: - barChartConfiguration.secondaryAxisGroupByDateGranularity ?? - undefined, + chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined, }), ); } @@ -77,33 +75,31 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ | ObjectRecordOrderByForCompositeField > = []; - if (isDefined(barChartConfiguration.primaryAxisOrderBy)) { + if (isDefined(chartConfiguration.primaryAxisOrderBy)) { orderBy.push( getGroupByOrderBy({ - graphOrderBy: barChartConfiguration.primaryAxisOrderBy, + graphOrderBy: chartConfiguration.primaryAxisOrderBy, groupByField: groupByFieldX, - groupBySubFieldName: - barChartConfiguration.primaryAxisGroupBySubFieldName, + groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName, aggregateOperation, dateGranularity: - barChartConfiguration.primaryAxisDateGranularity ?? undefined, + chartConfiguration.primaryAxisDateGranularity ?? undefined, }), ); } if ( isDefined(groupByFieldY) && - isDefined(barChartConfiguration.secondaryAxisOrderBy) + isDefined(chartConfiguration.secondaryAxisOrderBy) ) { orderBy.push( getGroupByOrderBy({ - graphOrderBy: barChartConfiguration.secondaryAxisOrderBy, + graphOrderBy: chartConfiguration.secondaryAxisOrderBy, groupByField: groupByFieldY, groupBySubFieldName: - barChartConfiguration.secondaryAxisGroupBySubFieldName, + chartConfiguration.secondaryAxisGroupBySubFieldName, aggregateOperation, dateGranularity: - barChartConfiguration.secondaryAxisGroupByDateGranularity ?? - undefined, + chartConfiguration.secondaryAxisGroupByDateGranularity ?? undefined, }), ); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartDataPoints.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartDataPoints.ts new file mode 100644 index 0000000000..a0e1c49c20 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartDataPoints.ts @@ -0,0 +1,30 @@ +import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; +import { GraphOrderBy } from '~/generated/graphql'; + +const normalizeXValue = (x: number | string | Date): string => { + if (x instanceof Date) { + return x.toISOString(); + } + return String(x); +}; + +export const sortLineChartDataPoints = ({ + dataPoints, + orderBy, +}: { + dataPoints: LineChartDataPoint[]; + orderBy?: GraphOrderBy | null; +}): LineChartDataPoint[] => { + switch (orderBy) { + case GraphOrderBy.FIELD_ASC: + return [...dataPoints].sort((a, b) => + normalizeXValue(a.x).localeCompare(normalizeXValue(b.x)), + ); + case GraphOrderBy.FIELD_DESC: + return [...dataPoints].sort((a, b) => + normalizeXValue(b.x).localeCompare(normalizeXValue(a.x)), + ); + default: + return dataPoints; + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts new file mode 100644 index 0000000000..6ae94f9366 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts @@ -0,0 +1,19 @@ +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { GraphOrderBy } from '~/generated/graphql'; + +export const sortLineChartSeries = ({ + series, + orderByY, +}: { + series: LineChartSeries[]; + orderByY?: GraphOrderBy | null; +}): LineChartSeries[] => { + switch (orderByY) { + case GraphOrderBy.FIELD_ASC: + return [...series].sort((a, b) => b.id.localeCompare(a.id)); + case GraphOrderBy.FIELD_DESC: + return [...series].sort((a, b) => a.id.localeCompare(b.id)); + default: + return series; + } +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts new file mode 100644 index 0000000000..ea107052d4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts @@ -0,0 +1,140 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; +import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName'; +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; +import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData'; +import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData'; +import { isDefined } from 'twenty-shared/utils'; +import { + AxisNameDisplay, + type LineChartConfiguration, +} from '~/generated/graphql'; + +type TransformGroupByDataToLineChartDataParams = { + groupByData: Record | null | undefined; + objectMetadataItem: ObjectMetadataItem; + configuration: LineChartConfiguration; + aggregateOperation: string; +}; + +type TransformGroupByDataToLineChartDataResult = { + series: LineChartSeries[]; + xAxisLabel?: string; + yAxisLabel?: string; + showDataLabels: boolean; + hasTooManyGroups: boolean; +}; + +const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = { + series: [], + xAxisLabel: undefined, + yAxisLabel: undefined, + showDataLabels: false, + hasTooManyGroups: false, +}; + +export const transformGroupByDataToLineChartData = ({ + groupByData, + objectMetadataItem, + configuration, + aggregateOperation, +}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => { + if (!isDefined(groupByData)) { + return EMPTY_LINE_CHART_RESULT; + } + + const groupByFieldX = objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.primaryAxisGroupByFieldMetadataId, + ); + + const groupByFieldY = isDefined( + configuration.secondaryAxisGroupByFieldMetadataId, + ) + ? objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.secondaryAxisGroupByFieldMetadataId, + ) + : undefined; + + const aggregateField = objectMetadataItem.fields.find( + (field: FieldMetadataItem) => + field.id === configuration.aggregateFieldMetadataId, + ); + + if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) { + return EMPTY_LINE_CHART_RESULT; + } + + const primaryAxisSubFieldName = + configuration.primaryAxisGroupBySubFieldName ?? undefined; + + const queryName = getGroupByQueryName(objectMetadataItem); + const rawResults = groupByData[queryName]; + + if (!isDefined(rawResults) || !Array.isArray(rawResults)) { + return EMPTY_LINE_CHART_RESULT; + } + + const filteredResults = filterGroupByResults({ + rawResults, + filterOptions: { + rangeMin: configuration.rangeMin ?? undefined, + rangeMax: configuration.rangeMax ?? undefined, + omitNullValues: configuration.omitNullValues ?? false, + }, + aggregateField, + aggregateOperation: + configuration.aggregateOperation as unknown as ExtendedAggregateOperations, + aggregateOperationFromRawResult: aggregateOperation, + objectMetadataItem, + }); + + const showXAxis = + configuration.axisNameDisplay === AxisNameDisplay.X || + configuration.axisNameDisplay === AxisNameDisplay.BOTH; + + const showYAxis = + configuration.axisNameDisplay === AxisNameDisplay.Y || + configuration.axisNameDisplay === AxisNameDisplay.BOTH; + + const xAxisLabel = showXAxis ? groupByFieldX.label : undefined; + + const yAxisLabel = showYAxis + ? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}` + : undefined; + + const showDataLabels = configuration.displayDataLabel ?? false; + + const baseResult = isDefined(groupByFieldY) + ? transformTwoDimensionalGroupByToLineChartData({ + rawResults: filteredResults, + groupByFieldX, + groupByFieldY, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + primaryAxisSubFieldName, + }) + : transformOneDimensionalGroupByToLineChartData({ + rawResults: filteredResults, + groupByFieldX, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + primaryAxisSubFieldName, + }); + + return { + ...baseResult, + xAxisLabel, + yAxisLabel, + showDataLabels, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts new file mode 100644 index 0000000000..27b0918c89 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts @@ -0,0 +1,100 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant'; +import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant'; +import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; +import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints'; +import { isDefined } from 'twenty-shared/utils'; +import { type LineChartConfiguration } from '~/generated/graphql'; + +type TransformOneDimensionalGroupByToLineChartDataParams = { + rawResults: GroupByRawResult[]; + groupByFieldX: FieldMetadataItem; + aggregateField: FieldMetadataItem; + configuration: LineChartConfiguration; + aggregateOperation: string; + objectMetadataItem: ObjectMetadataItem; + primaryAxisSubFieldName?: string | null; +}; + +type TransformOneDimensionalGroupByToLineChartDataResult = { + series: LineChartSeries[]; + hasTooManyGroups: boolean; +}; + +export const transformOneDimensionalGroupByToLineChartData = ({ + rawResults, + groupByFieldX, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + primaryAxisSubFieldName, +}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => { + // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) + const limitedResults = rawResults.slice( + 0, + LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS, + ); + + const unsortedData: LineChartDataPoint[] = limitedResults + .map((result) => { + const dimensionValues = result.groupByDimensionValues; + + const rawAggregateValue = result[aggregateOperation]; + if (!isDefined(rawAggregateValue)) { + return null; + } + + const xValue = isDefined(dimensionValues?.[0]) + ? formatDimensionValue({ + value: dimensionValues[0], + fieldMetadata: groupByFieldX, + dateGranularity: + configuration.primaryAxisDateGranularity ?? undefined, + subFieldName: primaryAxisSubFieldName ?? undefined, + }) + : ''; + + const aggregateValue = computeAggregateValueFromGroupByResult({ + rawResult: result, + aggregateField, + aggregateOperation: + configuration.aggregateOperation as unknown as ExtendedAggregateOperations, + aggregateOperationFromRawResult: aggregateOperation, + objectMetadataItem, + }); + + return { + x: xValue, + y: aggregateValue, + }; + }) + .filter((point) => isDefined(point)); + + const data = sortLineChartDataPoints({ + dataPoints: unsortedData, + orderBy: configuration.primaryAxisOrderBy, + }); + + const series: LineChartSeries[] = [ + { + id: aggregateField.name, + label: aggregateField.label, + color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor, + data, + }, + ]; + + return { + series, + hasTooManyGroups: + rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts new file mode 100644 index 0000000000..deecc66a37 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts @@ -0,0 +1,130 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartMaximumNumberOfDataPoints.constant'; +import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; +import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; +import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; +import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { sortLineChartDataPoints } from '@/page-layout/widgets/graph/utils/sortLineChartDataPoints'; +import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries'; +import { isDefined } from 'twenty-shared/utils'; +import { type LineChartConfiguration } from '~/generated/graphql'; + +type TransformTwoDimensionalGroupByToLineChartDataParams = { + rawResults: GroupByRawResult[]; + groupByFieldX: FieldMetadataItem; + groupByFieldY: FieldMetadataItem; + aggregateField: FieldMetadataItem; + configuration: LineChartConfiguration; + aggregateOperation: string; + objectMetadataItem: ObjectMetadataItem; + primaryAxisSubFieldName?: string | null; +}; + +type TransformTwoDimensionalGroupByToLineChartDataResult = { + series: LineChartSeries[]; + hasTooManyGroups: boolean; +}; + +export const transformTwoDimensionalGroupByToLineChartData = ({ + rawResults, + groupByFieldX, + groupByFieldY, + aggregateField, + configuration, + aggregateOperation, + objectMetadataItem, + primaryAxisSubFieldName, +}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => { + const seriesMap = new Map>(); + const allXValues: string[] = []; + const xValueSet = new Set(); + let hasTooManyGroups = false; + + rawResults.forEach((result) => { + const dimensionValues = result.groupByDimensionValues; + if (!isDefined(dimensionValues) || dimensionValues.length < 2) return; + + const rawAggregateValue = result[aggregateOperation]; + if (!isDefined(rawAggregateValue)) return; + + const xValue = formatDimensionValue({ + value: dimensionValues[0], + fieldMetadata: groupByFieldX, + dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, + subFieldName: primaryAxisSubFieldName ?? undefined, + }); + + // TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600) + const isNewX = !xValueSet.has(xValue); + + if (isNewX && xValueSet.size >= LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS) { + hasTooManyGroups = true; + return; + } + + if (isNewX) { + xValueSet.add(xValue); + allXValues.push(xValue); + } + + const seriesKey = formatDimensionValue({ + value: dimensionValues[1], + fieldMetadata: groupByFieldY, + dateGranularity: + configuration.secondaryAxisGroupByDateGranularity ?? undefined, + subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined, + }); + + const aggregateValue = computeAggregateValueFromGroupByResult({ + rawResult: result, + aggregateField, + aggregateOperation: + configuration.aggregateOperation as unknown as ExtendedAggregateOperations, + aggregateOperationFromRawResult: aggregateOperation, + objectMetadataItem, + }); + + if (!isDefined(aggregateValue)) return; + + if (!seriesMap.has(seriesKey)) { + seriesMap.set(seriesKey, new Map()); + } + + seriesMap.get(seriesKey)!.set(xValue, aggregateValue); + }); + + const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map( + ([seriesKey, xToYMap]) => { + const unsortedData: LineChartDataPoint[] = allXValues.map((xValue) => ({ + x: xValue, + y: xToYMap.get(xValue) ?? 0, + })); + + const data = sortLineChartDataPoints({ + dataPoints: unsortedData, + orderBy: configuration.primaryAxisOrderBy, + }); + + return { + id: seriesKey, + label: seriesKey, + color: configuration.color as GraphColor, + data, + }; + }, + ); + + const series = sortLineChartSeries({ + series: unsortedSeries, + orderByY: configuration.secondaryAxisOrderBy, + }); + + return { + series, + hasTooManyGroups, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/states/widgetCardHoveredComponentFamilyState.ts b/packages/twenty-front/src/modules/page-layout/widgets/states/widgetCardHoveredComponentFamilyState.ts new file mode 100644 index 0000000000..5c61c78fd8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/states/widgetCardHoveredComponentFamilyState.ts @@ -0,0 +1,11 @@ +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const widgetCardHoveredComponentFamilyState = createComponentFamilyState< + boolean, + string +>({ + key: 'widgetCardHoveredComponentFamilyState', + defaultValue: false, + componentInstanceContext: PageLayoutComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx index 9a5cb15f24..59f1b7a89b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx @@ -1,3 +1,5 @@ +import { widgetCardHoveredComponentFamilyState } from '@/page-layout/widgets/states/widgetCardHoveredComponentFamilyState'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; @@ -10,7 +12,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { isDefined } from 'twenty-shared/utils'; export type WidgetCardHeaderProps = { - isWidgetCardHovered: boolean; + widgetId: string; isInEditMode: boolean; isEmpty?: boolean; title: string; @@ -50,7 +52,7 @@ const StyledIconButtonContainer = styled(motion.div)` `; export const WidgetCardHeader = ({ - isWidgetCardHovered = false, + widgetId, isEmpty = false, isInEditMode = false, isResizing = false, @@ -61,6 +63,11 @@ export const WidgetCardHeader = ({ }: WidgetCardHeaderProps) => { const theme = useTheme(); + const isWidgetCardHovered = useRecoilComponentFamilyValue( + widgetCardHoveredComponentFamilyState, + widgetId, + ); + return ( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx index 798c0ed5b7..6268cfce70 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx @@ -86,7 +86,7 @@ export const Default: Story = { isDragging={args.isDragging} > {}} title="Widget name" @@ -195,13 +195,13 @@ export const Catalog: CatalogStory = { isInPinnedTab={isInPinnedTab} > : undefined } isInEditMode={!isReadMode} onRemove={!isReadMode ? () => {} : undefined} title="Widget name" - isWidgetCardHovered={args.state === 'Hover'} /> Boolean, { + nullable: true, + }) + @IsBoolean() + @IsOptional() + isStacked?: boolean; + @Field(() => String, { nullable: true, defaultValue: 'UTC' }) @IsTimeZone() @IsOptional()