diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 57f013e152..e037a09e5b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -152,6 +152,7 @@ export type AggregateChartConfiguration = { graphType: GraphType; label?: Maybe; prefix?: Maybe; + ratioAggregateConfig?: Maybe; suffix?: Maybe; timezone?: Maybe; }; @@ -3695,6 +3696,17 @@ export type QueueRetentionConfig = { failedMaxCount: Scalars['Float']; }; +export type RatioAggregateConfig = { + __typename?: 'RatioAggregateConfig'; + fieldMetadataId: Scalars['UUID']; + optionValue: Scalars['String']; +}; + +export type RatioAggregateConfigInput = { + fieldMetadataId: Scalars['UUID']; + optionValue: Scalars['String']; +}; + export type Relation = { __typename?: 'Relation'; sourceFieldMetadata: Field; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index f2b5b67edd..fc2b710a47 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -152,6 +152,7 @@ export type AggregateChartConfiguration = { graphType: GraphType; label?: Maybe; prefix?: Maybe; + ratioAggregateConfig?: Maybe; suffix?: Maybe; timezone?: Maybe; }; @@ -3546,6 +3547,17 @@ export type QueueRetentionConfig = { failedMaxCount: Scalars['Float']; }; +export type RatioAggregateConfig = { + __typename?: 'RatioAggregateConfig'; + fieldMetadataId: Scalars['UUID']; + optionValue: Scalars['String']; +}; + +export type RatioAggregateConfigInput = { + fieldMetadataId: Scalars['UUID']; + optionValue: Scalars['String']; +}; + export type Relation = { __typename?: 'Relation'; sourceFieldMetadata: Field; @@ -4825,7 +4837,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, prefix?: string | null, suffix?: string | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | 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, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null }; export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{ id: Scalars['String']; @@ -4833,7 +4845,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, prefix?: string | null, suffix?: string | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | 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, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | 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, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null }> | null }> | null } }; export type OnDbEventSubscriptionVariables = Exact<{ input: OnDbEventInput; @@ -5226,6 +5238,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql` suffix timezone firstDayOfTheWeek + ratioAggregateConfig { + fieldMetadataId + optionValue + } } ... on GaugeChartConfiguration { graphType diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectableListItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectableListItem.tsx new file mode 100644 index 0000000000..28599dcd0f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectableListItem.tsx @@ -0,0 +1,91 @@ +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; +import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; +import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { type AggregateChartOperation } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation'; +import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; +import { MenuItemSelect } from 'twenty-ui/navigation'; + +const isExtendedAggregateOperation = ( + operation: AggregateChartOperation, +): operation is ExtendedAggregateOperations => { + return operation !== DASHBOARD_AGGREGATE_OPERATION_RATIO; +}; + +export const ChartAggregateOperationSelectableListItem = ({ + operation, + label, + currentFieldMetadataId, +}: { + operation: AggregateChartOperation; + label: string; + currentFieldMetadataId: string; +}) => { + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); + const { updateCurrentWidgetConfig } = + useUpdateCurrentWidgetConfig(pageLayoutId); + const { closeDropdown } = useCloseDropdown(); + + const dropdownId = useAvailableComponentInstanceIdOrThrow( + DropdownComponentInstanceContext, + ); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + dropdownId, + ); + + const configuration = widgetInEditMode?.configuration; + + const currentAggregateOperation = + configuration && + 'aggregateOperation' in configuration && + configuration.aggregateOperation; + + const isCurrentlyRatio = + configuration?.__typename === 'AggregateChartConfiguration' && + isDefined(configuration.ratioAggregateConfig); + + if (!isExtendedAggregateOperation(operation)) { + return null; + } + + const aggregateOperation = + convertExtendedAggregateOperationToAggregateOperation(operation); + + const isSelected = + currentAggregateOperation === aggregateOperation && !isCurrentlyRatio; + + const isFocused = selectedItemId === operation; + + const handleClick = () => { + updateCurrentWidgetConfig({ + configToUpdate: { + aggregateFieldMetadataId: currentFieldMetadataId, + aggregateOperation, + ratioAggregateConfig: null, + }, + }); + closeDropdown(); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectionDropdownContent.tsx index f40da8d97c..aacf7113ab 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectionDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectionDropdownContent.tsx @@ -1,29 +1,28 @@ +import { ChartAggregateOperationSelectableListItem } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartAggregateOperationSelectableListItem'; +import { ChartRatioAggregateOperationSelectableListItem } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartRatioAggregateOperationSelectableListItem'; +import { ChartRatioOptionValueSelectionDropdownContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionValueSelectionDropdownContent'; import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; -import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { DateAggregateOperations } from '@/object-record/record-table/constants/DateAggregateOperations'; import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; -import { convertExtendedAggregateOperationToAggregateOperation } from '@/object-record/utils/convertExtendedAggregateOperationToAggregateOperation'; +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { type AggregateChartOperation } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation'; +import { getAggregateChartOperationLabel } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAggregateChartOperationLabel'; +import { getAvailableAggregateOperationsForAggregateChart } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAvailableAggregateOperationsForAggregateChart'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; -import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; -import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; -import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { t } from '@lingui/core/macro'; import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { IconChevronLeft } from 'twenty-ui/display'; -import { MenuItemSelect } from 'twenty-ui/navigation'; -import { GraphType, type AggregateOperations } from '~/generated/graphql'; +import { GraphType } from '~/generated/graphql'; import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; export const ChartAggregateOperationSelectionDropdownContent = ({ @@ -34,6 +33,7 @@ export const ChartAggregateOperationSelectionDropdownContent = ({ setIsSubMenuOpen: (isSubMenuOpen: boolean) => void; }) => { const [searchQuery, setSearchQuery] = useState(''); + const [isOptionValueMenuOpen, setIsOptionValueMenuOpen] = useState(false); const { objectMetadataItems } = useObjectMetadataItems(); const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); @@ -48,9 +48,6 @@ export const ChartAggregateOperationSelectionDropdownContent = ({ throw new Error('Invalid configuration type'); } - const currentAggregateOperation = - widgetInEditMode.configuration.aggregateOperation; - const sourceObjectMetadataItem = objectMetadataItems.find( (item) => item.id === widgetInEditMode.objectMetadataId, ); @@ -63,21 +60,23 @@ export const ChartAggregateOperationSelectionDropdownContent = ({ DropdownComponentInstanceContext, ); - const selectedItemId = useRecoilComponentValue( - selectedItemIdComponentState, - dropdownId, - ); - - const availableAggregateOperations = selectedField - ? getAvailableAggregateOperationsForFieldMetadataType({ - fieldMetadataType: selectedField.type, - }) - : []; + const isAggregateChart = + widgetInEditMode.configuration.graphType === GraphType.AGGREGATE; const isAggregateOrGaugeChart = - widgetInEditMode.configuration.graphType === GraphType.AGGREGATE || + isAggregateChart || widgetInEditMode.configuration.graphType === GraphType.GAUGE; + const availableAggregateOperations: AggregateChartOperation[] = selectedField + ? isAggregateChart + ? getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: selectedField.type, + }) + : getAvailableAggregateOperationsForFieldMetadataType({ + fieldMetadataType: selectedField.type, + }) + : []; + const filteredAggregateOperations = availableAggregateOperations.filter( (operation) => { return ( @@ -91,7 +90,7 @@ export const ChartAggregateOperationSelectionDropdownContent = ({ const aggregateOperationsWithLabels = filteredAggregateOperations.map( (operation) => ({ operation, - label: getAggregateOperationLabel(operation), + label: getAggregateChartOperationLabel(operation), }), ); @@ -101,27 +100,23 @@ export const ChartAggregateOperationSelectionDropdownContent = ({ getSearchableValues: (item) => [item.label], }); - const { updateCurrentWidgetConfig } = - useUpdateCurrentWidgetConfig(pageLayoutId); - - const { closeDropdown } = useCloseDropdown(); - if (!isDefined(sourceObjectMetadataItem) || !isDefined(selectedField)) { return null; } - const handleSelectAggregateOperation = ( - aggregateOperation: AggregateOperations, - ) => { - updateCurrentWidgetConfig({ - configToUpdate: { - aggregateFieldMetadataId: currentFieldMetadataId, - aggregateOperation, - }, - }); - closeDropdown(); + const handleSelectRatio = () => { + setIsOptionValueMenuOpen(true); }; + if (isOptionValueMenuOpen) { + return ( + + ); + } + return ( <> item.operation, )} > - {filteredAggregateOperationsWithLabels.map((item) => ( - { - handleSelectAggregateOperation( - convertExtendedAggregateOperationToAggregateOperation( - item.operation, - ), - ); - }} - > - { - handleSelectAggregateOperation( - convertExtendedAggregateOperationToAggregateOperation( - item.operation, - ), - ); - }} + {filteredAggregateOperationsWithLabels.map((item) => + item.operation === DASHBOARD_AGGREGATE_OPERATION_RATIO ? ( + - - ))} + ) : ( + + ), + )} diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDataSourceDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDataSourceDropdownContent.tsx index 132dcd4d62..4f8a7c78fc 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDataSourceDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDataSourceDropdownContent.tsx @@ -101,6 +101,7 @@ export const ChartDataSourceDropdownContent = () => { groupByFieldMetadataId: null, groupBySubFieldName: null, filter: {}, + ratioAggregateConfig: null, }, }); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioAggregateOperationSelectableListItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioAggregateOperationSelectableListItem.tsx new file mode 100644 index 0000000000..78a1add727 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioAggregateOperationSelectableListItem.tsx @@ -0,0 +1,52 @@ +import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; +import { MenuItemSelect } from 'twenty-ui/navigation'; + +export const ChartRatioAggregateOperationSelectableListItem = ({ + label, + onSelect, +}: { + label: string; + onSelect: () => void; +}) => { + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); + + const dropdownId = useAvailableComponentInstanceIdOrThrow( + DropdownComponentInstanceContext, + ); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + dropdownId, + ); + + const isCurrentlyRatio = + widgetInEditMode?.configuration?.__typename === + 'AggregateChartConfiguration' && + isDefined(widgetInEditMode.configuration.ratioAggregateConfig); + + const isFocused = selectedItemId === DASHBOARD_AGGREGATE_OPERATION_RATIO; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionBooleanSelectableListItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionBooleanSelectableListItem.tsx new file mode 100644 index 0000000000..5280d6a373 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionBooleanSelectableListItem.tsx @@ -0,0 +1,72 @@ +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; +import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; +import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { IconCheck, IconX } from 'twenty-ui/display'; +import { MenuItemSelect } from 'twenty-ui/navigation'; +import { AggregateOperations } from '~/generated/graphql'; + +export const ChartRatioOptionBooleanSelectableListItem = ({ + optionValue, + label, + currentFieldMetadataId, +}: { + optionValue: string; + label: string; + currentFieldMetadataId: string; +}) => { + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); + const { updateCurrentWidgetConfig } = + useUpdateCurrentWidgetConfig(pageLayoutId); + const { closeDropdown } = useCloseDropdown(); + + const dropdownId = useAvailableComponentInstanceIdOrThrow( + DropdownComponentInstanceContext, + ); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + dropdownId, + ); + + const currentRatioConfig = + widgetInEditMode?.configuration?.__typename === + 'AggregateChartConfiguration' + ? widgetInEditMode.configuration.ratioAggregateConfig + : undefined; + + const isSelected = currentRatioConfig?.optionValue === optionValue; + const isFocused = selectedItemId === optionValue; + + const handleClick = () => { + updateCurrentWidgetConfig({ + configToUpdate: { + aggregateFieldMetadataId: currentFieldMetadataId, + aggregateOperation: AggregateOperations.COUNT, + ratioAggregateConfig: { + fieldMetadataId: currentFieldMetadataId, + optionValue, + }, + }, + }); + closeDropdown(); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionSelectSelectableListItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionSelectSelectableListItem.tsx new file mode 100644 index 0000000000..c1daa1564d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionSelectSelectableListItem.tsx @@ -0,0 +1,74 @@ +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; +import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; +import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { MenuItemSelectTag } from 'twenty-ui/navigation'; +import { type ThemeColor } from 'twenty-ui/theme'; +import { AggregateOperations } from '~/generated/graphql'; + +export const ChartRatioOptionSelectSelectableListItem = ({ + optionValue, + label, + color, + currentFieldMetadataId, +}: { + optionValue: string; + label: string; + color: ThemeColor | undefined; + currentFieldMetadataId: string; +}) => { + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); + const { updateCurrentWidgetConfig } = + useUpdateCurrentWidgetConfig(pageLayoutId); + const { closeDropdown } = useCloseDropdown(); + + const dropdownId = useAvailableComponentInstanceIdOrThrow( + DropdownComponentInstanceContext, + ); + + const selectedItemId = useRecoilComponentValue( + selectedItemIdComponentState, + dropdownId, + ); + + const currentRatioConfig = + widgetInEditMode?.configuration?.__typename === + 'AggregateChartConfiguration' + ? widgetInEditMode.configuration.ratioAggregateConfig + : undefined; + + const isSelected = currentRatioConfig?.optionValue === optionValue; + const isFocused = selectedItemId === optionValue; + + const handleClick = () => { + updateCurrentWidgetConfig({ + configToUpdate: { + aggregateFieldMetadataId: currentFieldMetadataId, + aggregateOperation: AggregateOperations.COUNT, + ratioAggregateConfig: { + fieldMetadataId: currentFieldMetadataId, + optionValue, + }, + }, + }); + closeDropdown(); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionValueSelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionValueSelectionDropdownContent.tsx new file mode 100644 index 0000000000..a867bf950d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionValueSelectionDropdownContent.tsx @@ -0,0 +1,143 @@ +import { ChartRatioOptionBooleanSelectableListItem } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionBooleanSelectableListItem'; +import { ChartRatioOptionSelectSelectableListItem } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartRatioOptionSelectSelectableListItem'; +import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; +import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { IconChevronLeft } from 'twenty-ui/display'; +import { type ThemeColor } from 'twenty-ui/theme'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { filterBySearchQuery } from '~/utils/filterBySearchQuery'; + +type RatioOption = { + value: string; + label: string; + color?: ThemeColor; +}; + +export const ChartRatioOptionValueSelectionDropdownContent = ({ + currentFieldMetadataId, + setIsOptionValueMenuOpen, +}: { + currentFieldMetadataId: string; + setIsOptionValueMenuOpen: (isOpen: boolean) => void; +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const { objectMetadataItems } = useObjectMetadataItems(); + const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); + const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId); + + const dropdownId = useAvailableComponentInstanceIdOrThrow( + DropdownComponentInstanceContext, + ); + + if ( + widgetInEditMode?.configuration?.__typename !== + 'AggregateChartConfiguration' + ) { + return null; + } + + const sourceObjectMetadataItem = objectMetadataItems.find( + (item) => item.id === widgetInEditMode.objectMetadataId, + ); + + const selectedField = sourceObjectMetadataItem?.fields.find( + (field) => field.id === currentFieldMetadataId, + ); + + if (!isDefined(sourceObjectMetadataItem) || !isDefined(selectedField)) { + return null; + } + + const getOptionsForField = (): RatioOption[] => { + if (selectedField.type === FieldMetadataType.BOOLEAN) { + return [ + { value: 'true', label: t`True` }, + { value: 'false', label: t`False` }, + ]; + } + + if ( + selectedField.type === FieldMetadataType.SELECT || + selectedField.type === FieldMetadataType.MULTI_SELECT + ) { + const fieldOptions = selectedField.options ?? []; + return fieldOptions.map((option) => ({ + value: option.value, + label: option.label, + color: option.color as ThemeColor, + })); + } + + return []; + }; + + const isBoolean = selectedField.type === FieldMetadataType.BOOLEAN; + const options = getOptionsForField(); + + const filteredOptions = filterBySearchQuery({ + items: options, + searchQuery, + getSearchableValues: (item) => [item.label], + }); + + return ( + <> + setIsOptionValueMenuOpen(false)} + Icon={IconChevronLeft} + /> + } + > + {t`Select value`} + + setSearchQuery(event.target.value)} + value={searchQuery} + /> + + + item.value)} + > + {filteredOptions.map((option) => + isBoolean ? ( + + ) : ( + + ), + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts index e1586d73ce..0ace503ea2 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts @@ -9,7 +9,7 @@ import { getFieldLabelWithSubField } from '@/command-menu/pages/page-layout/util import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation'; -import { plural } from '@lingui/core/macro'; +import { plural, t } from '@lingui/core/macro'; import { useRecoilValue } from 'recoil'; import { type CompositeFieldSubFieldName } from 'twenty-shared/types'; import { capitalize, isDefined } from 'twenty-shared/utils'; @@ -163,14 +163,39 @@ export const useChartSettingsValues = ({ case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y: case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_AGGREGATE: case CHART_CONFIGURATION_SETTING_IDS.EACH_SLICE_REPRESENTS: { - const hasAggregateLabel = isDefined(aggregateField?.label); - const hasAggregateOperation = isDefined(aggregateOperation); + if ( + configuration.__typename === 'AggregateChartConfiguration' && + isDefined(configuration.ratioAggregateConfig) + ) { + const ratioField = objectMetadataItem?.fields.find( + (field) => + field.id === configuration.ratioAggregateConfig?.fieldMetadataId, + ); - return `${aggregateField?.label ?? ''}${ - hasAggregateLabel && hasAggregateOperation - ? ` (${getAggregateOperationLabel(aggregateOperation)})` - : '' - }`; + const optionValue = configuration.ratioAggregateConfig.optionValue; + const getBooleanLabel = (value: string) => + value === 'true' ? t`True` : t`False`; + + const ratioOptionLabel = + ratioField?.options?.find((option) => option.value === optionValue) + ?.label ?? + (ratioField?.type === 'BOOLEAN' + ? getBooleanLabel(optionValue) + : capitalize(optionValue)); + + return [aggregateField?.label, `(${t`Ratio`}: ${ratioOptionLabel})`] + .filter(isDefined) + .join(' '); + } + + const operationLabel = + isDefined(aggregateField?.label) && isDefined(aggregateOperation) + ? `(${getAggregateOperationLabel(aggregateOperation)})` + : undefined; + + return [aggregateField?.label, operationLabel] + .filter(isDefined) + .join(' '); } case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_PIE_CHART: { const pieChartGroupByField = isDefined(finalGroupByFieldYId) diff --git a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts index 5bb63c89fb..d99a5b11ac 100644 --- a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts +++ b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts @@ -98,6 +98,10 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql` suffix timezone firstDayOfTheWeek + ratioAggregateConfig { + fieldMetadataId + optionValue + } } ... on GaugeChartConfiguration { graphType diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant.ts new file mode 100644 index 0000000000..fb06fdfd0b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant.ts @@ -0,0 +1 @@ +export const DASHBOARD_AGGREGATE_OPERATION_RATIO = 'RATIO'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation.ts new file mode 100644 index 0000000000..3ef8263595 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation.ts @@ -0,0 +1,6 @@ +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { type DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; + +export type AggregateChartOperation = + | ExtendedAggregateOperations + | typeof DASHBOARD_AGGREGATE_OPERATION_RATIO; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/buildRatioNumeratorFilter.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/buildRatioNumeratorFilter.test.ts new file mode 100644 index 0000000000..9e80ca3dfe --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/buildRatioNumeratorFilter.test.ts @@ -0,0 +1,137 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { type RatioAggregateConfig } from '~/generated/graphql'; + +import { buildRatioNumeratorFilter } from '../buildRatioNumeratorFilter'; + +describe('buildRatioNumeratorFilter', () => { + const mockBooleanField = { + id: 'field-1', + name: 'isActive', + type: FieldMetadataType.BOOLEAN, + } as FieldMetadataItem; + + const mockSelectField = { + id: 'field-2', + name: 'stage', + type: FieldMetadataType.SELECT, + } as FieldMetadataItem; + + const mockMultiSelectField = { + id: 'field-3', + name: 'tags', + type: FieldMetadataType.MULTI_SELECT, + } as FieldMetadataItem; + + const baseFilter = { createdAt: { gte: '2024-01-01' } }; + + it('should return baseFilter when ratioConfig is undefined', () => { + const result = buildRatioNumeratorFilter({ + ratioConfig: undefined, + ratioField: mockBooleanField, + baseFilter, + }); + + expect(result).toEqual(baseFilter); + }); + + it('should return baseFilter when ratioField is undefined', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-1', + optionValue: 'true', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: undefined, + baseFilter, + }); + + expect(result).toEqual(baseFilter); + }); + + it('should build correct filter for BOOLEAN field with "true"', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-1', + optionValue: 'true', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: mockBooleanField, + baseFilter: undefined, + }); + + expect(result).toEqual({ + isActive: { eq: true }, + }); + }); + + it('should build correct filter for BOOLEAN field with "false"', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-1', + optionValue: 'false', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: mockBooleanField, + baseFilter: undefined, + }); + + expect(result).toEqual({ + isActive: { eq: false }, + }); + }); + + it('should build correct filter for SELECT field', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-2', + optionValue: 'WON', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: mockSelectField, + baseFilter: undefined, + }); + + expect(result).toEqual({ + stage: { eq: 'WON' }, + }); + }); + + it('should build correct filter for MULTI_SELECT field using containsAny', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-3', + optionValue: 'urgent', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: mockMultiSelectField, + baseFilter: undefined, + }); + + expect(result).toEqual({ + tags: { containsAny: ['urgent'] }, + }); + }); + + it('should combine with baseFilter using AND when both exist', () => { + const ratioConfig: RatioAggregateConfig = { + fieldMetadataId: 'field-2', + optionValue: 'WON', + }; + + const result = buildRatioNumeratorFilter({ + ratioConfig, + ratioField: mockSelectField, + baseFilter, + }); + + expect(result).toEqual({ + and: [baseFilter, { stage: { eq: 'WON' } }], + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/computeRatioDisplayValue.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/computeRatioDisplayValue.test.ts new file mode 100644 index 0000000000..67fd2b52b3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/computeRatioDisplayValue.test.ts @@ -0,0 +1,57 @@ +import { computeRatioDisplayValue } from '../computeRatioDisplayValue'; + +describe('computeRatioDisplayValue', () => { + it('should return "0%" when denominator is 0', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 10, + denominatorCount: 0, + }); + + expect(result).toBe('0%'); + }); + + it('should return whole number without decimal when ratio is exact', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 50, + denominatorCount: 100, + }); + + expect(result).toBe('50%'); + }); + + it('should return one decimal place when ratio has decimals', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 1, + denominatorCount: 3, + }); + + expect(result).toBe('33.3%'); + }); + + it('should return "100%" when numerator equals denominator', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 100, + denominatorCount: 100, + }); + + expect(result).toBe('100%'); + }); + + it('should handle small percentages', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 1, + denominatorCount: 200, + }); + + expect(result).toBe('0.5%'); + }); + + it('should return "0%" when numerator is 0', () => { + const result = computeRatioDisplayValue({ + numeratorCount: 0, + denominatorCount: 100, + }); + + expect(result).toBe('0%'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/getAvailableAggregateOperationsForAggregateChart.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/getAvailableAggregateOperationsForAggregateChart.test.ts new file mode 100644 index 0000000000..a5c79f2c8f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/__tests__/getAvailableAggregateOperationsForAggregateChart.test.ts @@ -0,0 +1,63 @@ +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { getAvailableAggregateOperationsForAggregateChart } from '../getAvailableAggregateOperationsForAggregateChart'; + +describe('getAvailableAggregateOperationsForAggregateChart', () => { + it('should include RATIO for BOOLEAN field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.BOOLEAN, + }); + + expect(result).toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should include RATIO for SELECT field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.SELECT, + }); + + expect(result).toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should include RATIO for MULTI_SELECT field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.MULTI_SELECT, + }); + + expect(result).toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should not include RATIO for NUMBER field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.NUMBER, + }); + + expect(result).not.toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should not include RATIO for DATE field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.DATE, + }); + + expect(result).not.toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should not include RATIO for TEXT field', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: FieldMetadataType.TEXT, + }); + + expect(result).not.toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + }); + + it('should handle undefined fieldMetadataType', () => { + const result = getAvailableAggregateOperationsForAggregateChart({ + fieldMetadataType: undefined, + }); + + expect(result).not.toContain(DASHBOARD_AGGREGATE_OPERATION_RATIO); + expect(Array.isArray(result)).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/buildRatioNumeratorFilter.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/buildRatioNumeratorFilter.ts new file mode 100644 index 0000000000..61c1afd28d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/buildRatioNumeratorFilter.ts @@ -0,0 +1,61 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { type RatioAggregateConfig } from '~/generated/graphql'; + +export const buildRatioNumeratorFilter = ({ + ratioConfig, + ratioField, + baseFilter, +}: { + ratioConfig: RatioAggregateConfig | null | undefined; + ratioField: FieldMetadataItem | undefined; + baseFilter: RecordGqlOperationFilter | undefined; +}): RecordGqlOperationFilter | undefined => { + if (!isDefined(ratioConfig) || !isDefined(ratioField)) { + return baseFilter; + } + + const fieldFilter = buildFieldFilter( + ratioField.name, + ratioField.type, + ratioConfig.optionValue, + ); + + if (!isDefined(baseFilter)) { + return fieldFilter; + } + + return { + and: [baseFilter, fieldFilter], + }; +}; + +const buildFieldFilter = ( + fieldName: string, + fieldType: FieldMetadataType, + optionValue: string, +): RecordGqlOperationFilter => { + if (fieldType === FieldMetadataType.BOOLEAN) { + return { + [fieldName]: { + eq: optionValue === 'true', + }, + }; + } + + if (fieldType === FieldMetadataType.MULTI_SELECT) { + return { + [fieldName]: { + containsAny: [optionValue], + }, + }; + } + + return { + [fieldName]: { + eq: optionValue, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/computeRatioDisplayValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/computeRatioDisplayValue.ts new file mode 100644 index 0000000000..67da18c497 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/computeRatioDisplayValue.ts @@ -0,0 +1,20 @@ +export const computeRatioDisplayValue = ({ + numeratorCount, + denominatorCount, +}: { + numeratorCount: number; + denominatorCount: number; +}): string => { + if (denominatorCount === 0) { + return '0%'; + } + + const ratio = (numeratorCount / denominatorCount) * 100; + const formattedRatio = ratio.toFixed(1); + + if (formattedRatio.endsWith('.0')) { + return `${Math.round(ratio)}%`; + } + + return `${formattedRatio}%`; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAggregateChartOperationLabel.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAggregateChartOperationLabel.ts new file mode 100644 index 0000000000..5b31617612 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAggregateChartOperationLabel.ts @@ -0,0 +1,14 @@ +import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { type AggregateChartOperation } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation'; +import { t } from '@lingui/core/macro'; + +export const getAggregateChartOperationLabel = ( + operation: AggregateChartOperation, +) => { + if (operation === DASHBOARD_AGGREGATE_OPERATION_RATIO) { + return t`Ratio`; + } + + return getAggregateOperationLabel(operation); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAvailableAggregateOperationsForAggregateChart.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAvailableAggregateOperationsForAggregateChart.ts new file mode 100644 index 0000000000..e4227245c4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetAggregateChart/utils/getAvailableAggregateOperationsForAggregateChart.ts @@ -0,0 +1,25 @@ +import { getAvailableAggregateOperationsForFieldMetadataType } from '@/object-record/record-table/record-table-footer/utils/getAvailableAggregateOperationsForFieldMetadataType'; +import { DASHBOARD_AGGREGATE_OPERATION_RATIO } from '@/page-layout/widgets/graph/constants/DashboardAggregateOperationRatio.constant'; +import { type AggregateChartOperation } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/types/AggregateChartOperation'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getAvailableAggregateOperationsForAggregateChart = ({ + fieldMetadataType, +}: { + fieldMetadataType?: FieldMetadataType; +}): AggregateChartOperation[] => { + const baseOperations = getAvailableAggregateOperationsForFieldMetadataType({ + fieldMetadataType, + }); + + const isSelectOrBoolean = + fieldMetadataType === FieldMetadataType.SELECT || + fieldMetadataType === FieldMetadataType.MULTI_SELECT || + fieldMetadataType === FieldMetadataType.BOOLEAN; + + if (isSelectOrBoolean) { + return [...baseOperations, DASHBOARD_AGGREGATE_OPERATION_RATIO]; + } + + return baseOperations; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery.ts index f90456d735..f6c6c42a6f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery.ts @@ -1,19 +1,20 @@ import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords'; import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue'; import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; +import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel'; import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation'; +import { buildRatioNumeratorFilter } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/utils/buildRatioNumeratorFilter'; +import { computeRatioDisplayValue } from '@/page-layout/widgets/graph/graphWidgetAggregateChart/utils/computeRatioDisplayValue'; import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon'; import { UserContext } from '@/users/contexts/UserContext'; +import { t } from '@lingui/core/macro'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants'; import { findById, isDefined } from 'twenty-shared/utils'; -import { - AggregateOperations, - type AggregateChartConfiguration, -} from '~/generated/graphql'; +import { type AggregateChartConfiguration } from '~/generated/graphql'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; export const useGraphWidgetAggregateQuery = ({ @@ -30,6 +31,20 @@ export const useGraphWidgetAggregateQuery = ({ }); const aggregateOperation = configuration.aggregateOperation; + const ratioConfig = configuration.ratioAggregateConfig; + const isRatioQuery = isDefined(ratioConfig); + + const ratioField = isRatioQuery + ? objectMetadataItem.fields.find( + (field) => field.id === ratioConfig.fieldMetadataId, + ) + : undefined; + + const ratioNumeratorFilter = buildRatioNumeratorFilter({ + ratioConfig, + ratioField, + baseFilter: gqlOperationFilter, + }); const extendedAggregateOperation = convertAggregateOperationToExtendedAggregateOperation( @@ -43,11 +58,73 @@ export const useGraphWidgetAggregateQuery = ({ [aggregateField.name]: [extendedAggregateOperation], }, filter: gqlOperationFilter, + skip: isRatioQuery, + }); + + const { + data: ratioNumeratorData, + loading: ratioNumeratorLoading, + error: ratioNumeratorError, + } = useAggregateRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + recordGqlFieldsAggregate: { + [FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]: [AggregateOperations.COUNT], + }, + filter: ratioNumeratorFilter, + skip: !isRatioQuery, + }); + + const { + data: ratioDenominatorData, + loading: ratioDenominatorLoading, + error: ratioDenominatorError, + } = useAggregateRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + recordGqlFieldsAggregate: { + [FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]: [AggregateOperations.COUNT], + }, + filter: gqlOperationFilter, + skip: !isRatioQuery, }); const { dateFormat, timeFormat, timeZone } = useContext(UserContext); const dateLocale = useRecoilValue(dateLocaleState); + if (isRatioQuery) { + const isRatioLoading = ratioNumeratorLoading || ratioDenominatorLoading; + const hasRatioData = + isDefined(ratioNumeratorData) && isDefined(ratioDenominatorData); + + if (isRatioLoading || !hasRatioData) { + return { + value: '-', + label: t`Ratio`, + loading: isRatioLoading, + error: ratioNumeratorError ?? ratioDenominatorError, + }; + } + + const numeratorCount = + ratioNumeratorData[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[ + AggregateOperations.COUNT + ] ?? 0; + + const denominatorCount = + ratioDenominatorData[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[ + AggregateOperations.COUNT + ] ?? 0; + + return { + value: computeRatioDisplayValue({ + numeratorCount: Number(numeratorCount), + denominatorCount: Number(denominatorCount), + }), + label: t`Ratio`, + loading: false, + error: ratioNumeratorError ?? ratioDenominatorError, + }; + } + const aggregateFieldMetadataItem = objectMetadataItem.fields.find( findById(configuration.aggregateFieldMetadataId), ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/areChartConfigurationFieldsValidForQuery.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/areChartConfigurationFieldsValidForQuery.test.ts new file mode 100644 index 0000000000..bd517e1f2a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/areChartConfigurationFieldsValidForQuery.test.ts @@ -0,0 +1,213 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { + AggregateOperations, + GraphType, + type AggregateChartConfiguration, +} from '~/generated/graphql'; + +import { areChartConfigurationFieldsValidForQuery } from '../areChartConfigurationFieldsValidForQuery'; + +describe('areChartConfigurationFieldsValidForQuery', () => { + const createMockObjectMetadataItem = ( + fields: Array<{ + id: string; + type: FieldMetadataType; + options?: Array<{ value: string; label: string }>; + }>, + ): ObjectMetadataItem => + ({ + id: 'object-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: fields.map((f) => ({ + id: f.id, + name: f.id, + type: f.type, + label: f.id, + options: f.options, + })), + }) as ObjectMetadataItem; + + const createAggregateConfig = (ratioAggregateConfig?: { + fieldMetadataId: string; + optionValue: string; + }): AggregateChartConfiguration => ({ + __typename: 'AggregateChartConfiguration', + graphType: GraphType.AGGREGATE, + aggregateFieldMetadataId: 'aggregate-field', + aggregateOperation: AggregateOperations.COUNT, + ratioAggregateConfig: ratioAggregateConfig ?? null, + }); + + describe('AggregateChartConfiguration with ratioAggregateConfig', () => { + it('should return true when ratioAggregateConfig is undefined', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig(undefined), + objectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should return false when ratio field does not exist', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'non-existent-field', + optionValue: 'true', + }), + objectMetadataItem, + ); + + expect(result).toBe(false); + }); + + it('should return true for BOOLEAN field with "true" optionValue', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { id: 'boolean-field', type: FieldMetadataType.BOOLEAN }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'boolean-field', + optionValue: 'true', + }), + objectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should return true for BOOLEAN field with "false" optionValue', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { id: 'boolean-field', type: FieldMetadataType.BOOLEAN }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'boolean-field', + optionValue: 'false', + }), + objectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should return false for BOOLEAN field with invalid optionValue', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { id: 'boolean-field', type: FieldMetadataType.BOOLEAN }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'boolean-field', + optionValue: 'invalid', + }), + objectMetadataItem, + ); + + expect(result).toBe(false); + }); + + it('should return true for SELECT field with valid option', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { + id: 'select-field', + type: FieldMetadataType.SELECT, + options: [ + { value: 'WON', label: 'Won' }, + { value: 'LOST', label: 'Lost' }, + ], + }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'select-field', + optionValue: 'WON', + }), + objectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should return false for SELECT field with invalid option', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { + id: 'select-field', + type: FieldMetadataType.SELECT, + options: [ + { value: 'WON', label: 'Won' }, + { value: 'LOST', label: 'Lost' }, + ], + }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'select-field', + optionValue: 'INVALID_OPTION', + }), + objectMetadataItem, + ); + + expect(result).toBe(false); + }); + + it('should return true for MULTI_SELECT field with valid option', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { + id: 'multiselect-field', + type: FieldMetadataType.MULTI_SELECT, + options: [ + { value: 'urgent', label: 'Urgent' }, + { value: 'important', label: 'Important' }, + ], + }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'multiselect-field', + optionValue: 'urgent', + }), + objectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should return false for unsupported field type with ratioConfig', () => { + const objectMetadataItem = createMockObjectMetadataItem([ + { id: 'aggregate-field', type: FieldMetadataType.NUMBER }, + { id: 'text-field', type: FieldMetadataType.TEXT }, + ]); + + const result = areChartConfigurationFieldsValidForQuery( + createAggregateConfig({ + fieldMetadataId: 'text-field', + optionValue: 'some-value', + }), + objectMetadataItem, + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts index bb92036398..c13f2196cb 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery.ts @@ -1,6 +1,10 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isDefined } from 'twenty-shared/utils'; -import { type PageLayoutWidget } from '~/generated/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { + type PageLayoutWidget, + type RatioAggregateConfig, +} from '~/generated/graphql'; const fieldExists = ( fieldId: string | undefined | null, @@ -13,6 +17,39 @@ const fieldExists = ( return objectMetadataItem.fields.some((field) => field.id === fieldId); }; +const isRatioConfigValid = ( + ratioConfig: RatioAggregateConfig | null | undefined, + objectMetadataItem: ObjectMetadataItem, +): boolean => { + if (!isDefined(ratioConfig)) { + return true; + } + + const field = objectMetadataItem.fields.find( + (f) => f.id === ratioConfig.fieldMetadataId, + ); + + if (!isDefined(field)) { + return false; + } + + if (field.type === FieldMetadataType.BOOLEAN) { + return ( + ratioConfig.optionValue === 'true' || ratioConfig.optionValue === 'false' + ); + } + + if ( + field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT + ) { + const options = field.options ?? []; + return options.some((option) => option.value === ratioConfig.optionValue); + } + + return false; +}; + export const areChartConfigurationFieldsValidForQuery = ( configuration: PageLayoutWidget['configuration'], objectMetadataItem?: ObjectMetadataItem | null, @@ -54,6 +91,17 @@ export const areChartConfigurationFieldsValidForQuery = ( ); case 'AggregateChartConfiguration': + return ( + fieldExists( + configuration.aggregateFieldMetadataId, + objectMetadataItem, + ) && + isRatioConfigValid( + configuration.ratioAggregateConfig, + objectMetadataItem, + ) + ); + case 'GaugeChartConfiguration': return fieldExists( configuration.aggregateFieldMetadataId, diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/aggregate-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/aggregate-chart-configuration.dto.ts index 289a42589f..45af92fb20 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/aggregate-chart-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/aggregate-chart-configuration.dto.ts @@ -1,5 +1,6 @@ import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { Type } from 'class-transformer'; import { IsBoolean, IsEnum, @@ -11,6 +12,7 @@ import { IsUUID, Max, Min, + ValidateNested, } from 'class-validator'; import { GraphQLJSON } from 'graphql-type-json'; import { CalendarStartDay } from 'twenty-shared/constants'; @@ -19,6 +21,7 @@ import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-build import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { RatioAggregateConfigDTO } from 'src/engine/metadata-modules/page-layout/dtos/ratio-aggregate-config.dto'; import { GraphType } from 'src/engine/metadata-modules/page-layout/enums/graph-type.enum'; @ObjectType('AggregateChartConfiguration') @@ -83,4 +86,10 @@ export class AggregateChartConfigurationDTO { @IsString() @IsOptional() suffix?: string; + + @Field(() => RatioAggregateConfigDTO, { nullable: true }) + @ValidateNested() + @Type(() => RatioAggregateConfigDTO) + @IsOptional() + ratioAggregateConfig?: RatioAggregateConfigDTO; } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/ratio-aggregate-config.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/ratio-aggregate-config.dto.ts new file mode 100644 index 0000000000..fe2cb5e128 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/dtos/ratio-aggregate-config.dto.ts @@ -0,0 +1,19 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('RatioAggregateConfig') +@InputType('RatioAggregateConfigInput') +export class RatioAggregateConfigDTO { + @Field(() => UUIDScalarType) + @IsUUID() + @IsNotEmpty() + fieldMetadataId: string; + + @Field(() => String) + @IsString() + @IsNotEmpty() + optionValue: string; +}