From 226cc9eb74a40b471e98a23a2b4ac1d793b0df93 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:21:01 +0530 Subject: [PATCH] [Dashboards] - Min Max range on secondary axis bar charts (#15118) video QA https://github.com/user-attachments/assets/70c37188-2398-43de-bbf6-5882bb79940a --------- Co-authored-by: Charles Bochet --- .../components/CommandMenuItemNumberInput.tsx | 92 ++++++++ .../page-layout/components/ChartSettings.tsx | 168 ++++----------- .../chart-settings/ChartSettingItem.tsx | 132 ++++++++++++ .../constants/LineChartSettings.ts | 4 + .../ChartConfigurationSettingLabels.ts | 2 + .../constants/settings/RangeMaxSetting.ts | 14 ++ .../constants/settings/RangeMinSetting.ts | 14 ++ .../hooks/useChartSettingsValues.ts | 8 + .../hooks/useUpdateChartSettingInput.ts | 23 ++ .../hooks/useUpdateChartSettingToggle.ts | 53 +++++ .../types/ChartConfigurationSettingIds.ts | 4 + .../page-layout/types/ChartSettingsGroup.ts | 2 + .../__tests__/getBarChartSettings.test.ts | 10 +- .../__tests__/isChartSettingDisabled.test.ts | 179 ++++++++++++++++ .../__tests__/isMinMaxRangeValid.test.ts | 117 ++++++++++ .../page-layout/utils/getBarChartSettings.ts | 4 + .../utils/getConfigKeyFromSettingId.ts | 14 ++ .../utils/isChartSettingDisabled.ts | 22 ++ .../page-layout/utils/isMinMaxRangeValid.ts | 35 +++ .../components/GraphWidgetBarChart.tsx | 11 +- .../GraphWidgetBarChartRenderer.tsx | 23 +- .../generateGroupByQuery.test.ts.snap | 6 - .../__tests__/filterGroupByResults.test.ts | 200 ++++++++++++++++++ .../graph/utils/filterGroupByResults.ts | 69 ++++++ .../graph/utils/generateGroupByQuery.ts | 2 - ...QueryVariablesFromBarChartConfiguration.ts | 1 - .../transformGroupByDataToBarChartData.ts | 20 +- .../common-group-by-query-runner.service.ts | 27 --- .../common/types/common-query-args.type.ts | 1 - ...graphql-query-group-by-resolver.service.ts | 28 --- .../workspace-resolvers-builder.interface.ts | 1 - .../utils/get-resolver-args.util.ts | 5 +- .../handlers/rest-api-group-by.handler.ts | 14 +- .../group-by-resolvers.integration-spec.ts | 187 ---------------- .../utils/group-by-operation-factory.util.ts | 7 +- .../display/icon/components/TablerIcons.ts | 2 + packages/twenty-ui/src/display/index.ts | 2 + 37 files changed, 1096 insertions(+), 407 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/components/CommandMenuItemNumberInput.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMaxSetting.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMinSetting.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isChartSettingDisabled.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isMinMaxRangeValid.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartSettingDisabled.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isMinMaxRangeValid.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItemNumberInput.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItemNumberInput.tsx new file mode 100644 index 00000000000..84536220be3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItemNumberInput.tsx @@ -0,0 +1,92 @@ +import { TextInput } from '@/ui/input/components/TextInput'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { Key } from 'ts-key-enum'; +import { isDefined } from 'twenty-shared/utils'; +import { + canBeCastAsNumberOrNull, + castAsNumberOrNull, +} from '~/utils/cast-as-number-or-null'; + +type CommandMenuItemNumberInputProps = { + value: string; + onChange: (value: number | null) => void; + onValidate?: (value: number | null) => boolean; + placeholder?: string; +}; +const StyledRightAlignedTextInput = styled(TextInput)` + input { + :focus { + color: ${({ theme }) => theme.font.color.primary}; + } + color: ${({ theme }) => theme.font.color.tertiary}; + text-align: right; + } +`; +export const CommandMenuItemNumberInput = ({ + value, + onChange, + onValidate, + placeholder, +}: CommandMenuItemNumberInputProps) => { + const [draftValue, setDraftValue] = useState(value); + const [hasError, setHasError] = useState(false); + + const handleChange = (text: string) => { + setDraftValue(text); + if (hasError) { + setHasError(false); + } + }; + + const handleCommit = () => { + if (!canBeCastAsNumberOrNull(draftValue)) { + setHasError(true); + setDraftValue(value); + return; + } + + const numericValue = castAsNumberOrNull(draftValue); + + if (isDefined(onValidate)) { + const isInvalid = onValidate(numericValue); + if (isInvalid) { + setHasError(true); + return; + } + } + + onChange(numericValue); + setHasError(false); + }; + + const handleFocus = (event: React.FocusEvent) => { + event.target.select(); + }; + + const handleBlur = () => { + handleCommit(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === Key.Enter || event.key === Key.Escape) { + event.stopPropagation(); + handleCommit(); + } else { + event.stopPropagation(); + } + }; + + return ( + + ); +}; 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 21158c2ce4a..4e35aeb51bf 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 @@ -1,60 +1,61 @@ import { CommandGroup } from '@/command-menu/components/CommandGroup'; -import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; -import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown'; -import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle'; import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId'; import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo'; +import { ChartSettingItem } from '@/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem'; import { ChartTypeSelectionSection } from '@/command-menu/pages/page-layout/components/ChartTypeSelectionSection'; import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation'; import { GRAPH_TYPE_TO_CONFIG_TYPENAME } from '@/command-menu/pages/page-layout/constants/GraphTypeToConfigTypename'; import { useChartSettingsValues } from '@/command-menu/pages/page-layout/hooks/useChartSettingsValues'; import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; +import { useUpdateChartSettingInput } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput'; +import { useUpdateChartSettingToggle } from '@/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle'; import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig'; import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; -import { - CHART_CONFIGURATION_SETTING_IDS, - CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP, -} from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { isChartSettingDisabled } from '@/command-menu/pages/page-layout/utils/isChartSettingDisabled'; import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; -import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; -import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { t } from '@lingui/core/macro'; -import { isNonEmptyString } from '@sniptt/guards'; -import { - BarChartGroupMode, - type GraphType, - type PageLayoutWidget, -} from '~/generated/graphql'; +import { type GraphType, type PageLayoutWidget } from '~/generated/graphql'; export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo(); - const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu(); - const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); - const { updateCurrentWidgetConfig } = useUpdateCurrentWidgetConfig(pageLayoutId); - const { openDropdown } = useOpenDropdown(); + const { setSelectedItemId } = useSelectableList( + COMMAND_MENU_LIST_SELECTABLE_LIST_ID, + ); if (widget.configuration?.__typename === 'IframeConfiguration') { throw new Error(t`IframeConfiguration is not supported`); } const configuration = widget.configuration as ChartConfiguration; + const currentGraphType = configuration?.graphType; const { getChartSettingsValues } = useChartSettingsValues({ objectMetadataId: widget.objectMetadataId, configuration, }); - const currentGraphType = configuration?.graphType; + const { updateChartSettingToggle } = useUpdateChartSettingToggle({ + pageLayoutId, + objectMetadataId: widget.objectMetadataId, + configuration, + }); + + const { updateChartSettingInput } = useUpdateChartSettingInput(pageLayoutId); + + const isGroupByEnabled = getChartSettingsValues( + CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, + ); const handleGraphTypeChange = (graphType: GraphType) => { updateCurrentWidgetConfig({ @@ -71,14 +72,6 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { const chartSettings = GRAPH_TYPE_INFORMATION[currentGraphType].settings; - const { setSelectedItemId } = useSelectableList( - COMMAND_MENU_LIST_SELECTABLE_LIST_ID, - ); - - const isGroupByEnabled = getChartSettingsValues( - CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, - ); - return ( { {chartSettings.map((group) => ( {group.items.map((item) => { - const isDisabled = - (!isNonEmptyString(widget.objectMetadataId) && - (item?.dependsOn?.includes( - CHART_CONFIGURATION_SETTING_IDS.SOURCE, - ) ?? - false)) || - (!isGroupByEnabled && - item?.dependsOn?.includes( - CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, - )); - - const handleToggleChange = () => { - const configKey = - item.id in CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP - ? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[ - item.id as keyof typeof CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP - ] - : item.id; + const isDisabled = isChartSettingDisabled( + item, + widget.objectMetadataId, + isGroupByEnabled as boolean, + ); + const handleItemToggleChange = () => { setSelectedItemId(item.id); - - if (item.id === CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS) { - const isCurrentlyStacked = getChartSettingsValues(item.id); - const newGroupMode = isCurrentlyStacked - ? BarChartGroupMode.GROUPED - : BarChartGroupMode.STACKED; - - updateCurrentWidgetConfig({ - configToUpdate: { - groupMode: newGroupMode, - }, - }); - } else { - const newValue = !getChartSettingsValues(item.id); - - updateCurrentWidgetConfig({ - configToUpdate: { - [configKey]: newValue, - }, - }); - } + updateChartSettingToggle(item.id); }; - const handleDropdownOpen = () => { + const handleItemInputChange = (value: number | null) => { + updateChartSettingInput(item.id, value); + }; + + const handleItemDropdownOpen = () => { openDropdown({ dropdownComponentInstanceIdFromProps: item.id, }); }; - const handleFilterSettingsClick = () => { + const handleFilterClick = () => { navigatePageLayoutCommandMenu({ commandMenuPage: CommandMenuPages.PageLayoutGraphFilter, }); }; - if (item.id === CHART_CONFIGURATION_SETTING_IDS.FILTER) { - return ( - - - - ); - } - - return item.isBoolean ? ( - - - - ) : ( - - - {item.DropdownContent && } - - } - dropdownPlacement="bottom-end" - description={getChartSettingsValues(item.id) as string} - contextualTextPosition={'right'} - hasSubMenu - disabled={isDisabled} - /> - + item={item} + isDisabled={isDisabled} + configuration={configuration} + getChartSettingsValues={getChartSettingsValues} + onToggleChange={handleItemToggleChange} + onInputChange={handleItemInputChange} + onDropdownOpen={handleItemDropdownOpen} + onFilterClick={handleFilterClick} + /> ); })} diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem.tsx new file mode 100644 index 00000000000..331b7efafe9 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/chart-settings/ChartSettingItem.tsx @@ -0,0 +1,132 @@ +import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem'; +import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown'; +import { CommandMenuItemNumberInput } from '@/command-menu/components/CommandMenuItemNumberInput'; +import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle'; +import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { isMinMaxRangeValid } from '@/command-menu/pages/page-layout/utils/isMinMaxRangeValid'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { t } from '@lingui/core/macro'; +import { isString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +type ChartSettingItemProps = { + item: ChartSettingsItem; + isDisabled: boolean; + configuration: ChartConfiguration; + getChartSettingsValues: ( + itemId: CHART_CONFIGURATION_SETTING_IDS, + ) => boolean | string | undefined; + onToggleChange: () => void; + onInputChange: (value: number | null) => void; + onDropdownOpen: () => void; + onFilterClick: () => void; +}; + +export const ChartSettingItem = ({ + item, + isDisabled, + configuration, + getChartSettingsValues, + onToggleChange, + onInputChange, + onDropdownOpen, + onFilterClick, +}: ChartSettingItemProps) => { + if (item.id === CHART_CONFIGURATION_SETTING_IDS.FILTER) { + return ( + + + + ); + } + + if (isDefined(item.isInput)) { + const settingValue = getChartSettingsValues(item.id); + const stringValue = isString(settingValue) ? settingValue : ''; + + return ( + + + isDefined(value) && + isMinMaxRangeValid( + item.id as + | CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE + | CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + value, + configuration, + ) + } + placeholder={ + item.inputPlaceholder ? t(item.inputPlaceholder) : undefined + } + /> + } + /> + + ); + } + + if (item.isBoolean) { + return ( + + + + ); + } + + return ( + + + {item.DropdownContent && } + + } + dropdownPlacement="bottom-end" + description={getChartSettingsValues(item.id) as string} + contextualTextPosition={'right'} + hasSubMenu + disabled={isDisabled} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts index ce553dc1b88..4acf5746dfa 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts @@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/ import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; +import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting'; +import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; @@ -30,6 +32,8 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [ DATA_DISPLAY_Y_SETTING, GROUP_BY_SETTING, SORT_BY_GROUP_BY_FIELD_SETTING, + RANGE_MIN_SETTING, + RANGE_MAX_SETTING, ], }, { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts index f456c4d9018..9b1b426b458 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts @@ -16,4 +16,6 @@ export const CHART_CONFIGURATION_SETTING_LABELS = { AXIS_NAME: msg`Axis name`, STACKED_BARS: msg`Stacked bars`, OMIT_NULL_VALUES: msg`Omit zero values`, + MIN_RANGE: msg`Min range`, + MAX_RANGE: msg`Max range`, }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMaxSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMaxSetting.ts new file mode 100644 index 00000000000..cdc6d46827d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMaxSetting.ts @@ -0,0 +1,14 @@ +import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { msg } from '@lingui/core/macro'; +import { IconMathMax } from 'twenty-ui/display'; + +export const RANGE_MAX_SETTING: ChartSettingsItem = { + isBoolean: false, + Icon: IconMathMax, + label: CHART_CONFIGURATION_SETTING_LABELS.MAX_RANGE, + id: CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + isInput: true, + inputPlaceholder: msg`Max`, +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMinSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMinSetting.ts new file mode 100644 index 00000000000..c2bd25e4f66 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/RangeMinSetting.ts @@ -0,0 +1,14 @@ +import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { msg } from '@lingui/core/macro'; +import { IconMathMin } from 'twenty-ui/display'; + +export const RANGE_MIN_SETTING: ChartSettingsItem = { + isBoolean: false, + Icon: IconMathMin, + label: CHART_CONFIGURATION_SETTING_LABELS.MIN_RANGE, + id: CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + isInput: true, + inputPlaceholder: msg`Min`, +}; 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 26ec3345136..493c9e35327 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 @@ -160,6 +160,14 @@ export const useChartSettingsValues = ({ return 'omitNullValues' in configuration ? (configuration.omitNullValues ?? false) : false; + case CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE: + return 'rangeMin' in configuration + ? (configuration.rangeMin?.toString() ?? '') + : ''; + case CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE: + return 'rangeMax' in configuration + ? (configuration.rangeMax?.toString() ?? '') + : ''; default: return ''; } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput.ts new file mode 100644 index 00000000000..2d3d9e93b09 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingInput.ts @@ -0,0 +1,23 @@ +import { type CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { getConfigKeyFromSettingId } from '@/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId'; +import { useUpdateCurrentWidgetConfig } from './useUpdateCurrentWidgetConfig'; + +export const useUpdateChartSettingInput = (pageLayoutId: string) => { + const { updateCurrentWidgetConfig } = + useUpdateCurrentWidgetConfig(pageLayoutId); + + const updateChartSettingInput = ( + settingId: CHART_CONFIGURATION_SETTING_IDS, + value: number | null, + ) => { + const configKey = getConfigKeyFromSettingId(settingId); + + updateCurrentWidgetConfig({ + configToUpdate: { + [configKey]: value, + }, + }); + }; + + return { updateChartSettingInput }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle.ts new file mode 100644 index 00000000000..814af188c6e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useUpdateChartSettingToggle.ts @@ -0,0 +1,53 @@ +import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { getConfigKeyFromSettingId } from '@/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId'; +import { BarChartGroupMode } from '~/generated/graphql'; +import { useChartSettingsValues } from './useChartSettingsValues'; +import { useUpdateCurrentWidgetConfig } from './useUpdateCurrentWidgetConfig'; + +export const useUpdateChartSettingToggle = ({ + pageLayoutId, + objectMetadataId, + configuration, +}: { + pageLayoutId: string; + objectMetadataId: string; + configuration: ChartConfiguration; +}) => { + const { updateCurrentWidgetConfig } = + useUpdateCurrentWidgetConfig(pageLayoutId); + + const { getChartSettingsValues } = useChartSettingsValues({ + objectMetadataId, + configuration, + }); + + const updateChartSettingToggle = ( + settingId: CHART_CONFIGURATION_SETTING_IDS, + ) => { + const configKey = getConfigKeyFromSettingId(settingId); + + if (settingId === CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS) { + const isCurrentlyStacked = getChartSettingsValues(settingId); + const newGroupMode = isCurrentlyStacked + ? BarChartGroupMode.GROUPED + : BarChartGroupMode.STACKED; + + updateCurrentWidgetConfig({ + configToUpdate: { + groupMode: newGroupMode, + }, + }); + return; + } + + const newValue = !getChartSettingsValues(settingId); + updateCurrentWidgetConfig({ + configToUpdate: { + [configKey]: newValue, + }, + }); + }; + + return { updateChartSettingToggle }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts index 08b715f8215..ebc3cd9522b 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts @@ -15,10 +15,14 @@ export enum CHART_CONFIGURATION_SETTING_IDS { AXIS_NAME = 'AXIS_NAME', STACKED_BARS = 'STACKED_BARS', OMIT_NULL_VALUES = 'OMIT_NULL_VALUES', + MIN_RANGE = 'MIN_RANGE', + MAX_RANGE = 'MAX_RANGE', } export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = { [CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS]: 'displayDataLabel', [CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS]: 'groupMode', [CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES]: 'omitNullValues', + [CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE]: 'rangeMin', + [CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE]: 'rangeMax', } as const; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartSettingsGroup.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartSettingsGroup.ts index 20aa3fed415..1bf0ff644ca 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartSettingsGroup.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartSettingsGroup.ts @@ -16,4 +16,6 @@ export type ChartSettingsItem = { isBoolean: boolean; dependsOn?: CHART_CONFIGURATION_SETTING_IDS[]; DropdownContent?: ComponentType; + isInput?: boolean; + inputPlaceholder?: MessageDescriptor; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts index da723726901..e733516125a 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts @@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/ import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; +import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting'; +import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting'; @@ -36,12 +38,14 @@ describe('getBarChartSettings', () => { const yAxisGroup = result.find((group) => group.heading === 'Y axis'); expect(yAxisGroup).toBeDefined(); - expect(yAxisGroup?.items).toHaveLength(3); + expect(yAxisGroup?.items).toHaveLength(5); expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id); expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label); expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY); expect(yAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING); expect(yAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING); + expect(yAxisGroup?.items[3]).toEqual(RANGE_MIN_SETTING); + expect(yAxisGroup?.items[4]).toEqual(RANGE_MAX_SETTING); }); it('should have all expected groups in correct order', () => { @@ -62,12 +66,14 @@ describe('getBarChartSettings', () => { const xAxisGroup = result.find((group) => group.heading === 'X axis'); expect(xAxisGroup).toBeDefined(); - expect(xAxisGroup?.items).toHaveLength(3); + expect(xAxisGroup?.items).toHaveLength(5); expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id); expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label); expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX); expect(xAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING); expect(xAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING); + expect(xAxisGroup?.items[3]).toEqual(RANGE_MIN_SETTING); + expect(xAxisGroup?.items[4]).toEqual(RANGE_MAX_SETTING); }); it('should place PRIMARY axis items under "Y axis" heading', () => { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isChartSettingDisabled.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isChartSettingDisabled.test.ts new file mode 100644 index 00000000000..0edba268cb3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isChartSettingDisabled.test.ts @@ -0,0 +1,179 @@ +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { msg } from '@lingui/core/macro'; +import { IconChartBar } from 'twenty-ui/display'; +import { isChartSettingDisabled } from '../isChartSettingDisabled'; + +describe('isChartSettingDisabled', () => { + const mockItemWithoutDependencies: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS, + label: msg`Data Labels`, + Icon: IconChartBar, + isBoolean: true, + isInput: false, + }; + + const mockItemDependingOnSource: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X, + label: msg`X Axis Data`, + Icon: IconChartBar, + isBoolean: false, + isInput: false, + dependsOn: [CHART_CONFIGURATION_SETTING_IDS.SOURCE], + }; + + const mockItemDependingOnGroupBy: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD, + label: msg`Sort By Group`, + Icon: IconChartBar, + isBoolean: false, + isInput: false, + dependsOn: [CHART_CONFIGURATION_SETTING_IDS.GROUP_BY], + }; + + const mockItemWithMultipleDependencies: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X, + label: msg`X Axis Data`, + Icon: IconChartBar, + isBoolean: false, + isInput: false, + dependsOn: [ + CHART_CONFIGURATION_SETTING_IDS.SOURCE, + CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, + ], + }; + + describe('item without dependencies', () => { + it('should return false when item has no dependencies', () => { + const result = isChartSettingDisabled( + mockItemWithoutDependencies, + 'valid-object-id', + true, + ); + + expect(result).toBe(false); + }); + + it('should return false when item has no dependencies even with no object metadata', () => { + const result = isChartSettingDisabled( + mockItemWithoutDependencies, + '', + false, + ); + + expect(result).toBe(false); + }); + }); + + describe('item depending on SOURCE', () => { + it('should return true when no object metadata and item depends on SOURCE', () => { + const result = isChartSettingDisabled( + mockItemDependingOnSource, + '', + true, + ); + + expect(result).toBe(true); + }); + + it('should return false when object metadata exists and item depends on SOURCE', () => { + const result = isChartSettingDisabled( + mockItemDependingOnSource, + 'valid-object-id', + true, + ); + + expect(result).toBe(false); + }); + }); + + describe('item depending on GROUP_BY', () => { + it('should return true when group by is not enabled and item depends on GROUP_BY', () => { + const result = isChartSettingDisabled( + mockItemDependingOnGroupBy, + 'valid-object-id', + false, + ); + + expect(result).toBe(true); + }); + + it('should return false when group by is enabled and item depends on GROUP_BY', () => { + const result = isChartSettingDisabled( + mockItemDependingOnGroupBy, + 'valid-object-id', + true, + ); + + expect(result).toBe(false); + }); + }); + + describe('item with multiple dependencies', () => { + it('should return true if any dependency is not met (no object metadata)', () => { + const result = isChartSettingDisabled( + mockItemWithMultipleDependencies, + '', + true, + ); + + expect(result).toBe(true); + }); + + it('should return true if any dependency is not met (group by disabled)', () => { + const result = isChartSettingDisabled( + mockItemWithMultipleDependencies, + 'valid-object-id', + false, + ); + + expect(result).toBe(true); + }); + + it('should return false if all dependencies are met', () => { + const result = isChartSettingDisabled( + mockItemWithMultipleDependencies, + 'valid-object-id', + true, + ); + + expect(result).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle undefined dependsOn array', () => { + const itemWithUndefinedDependsOn: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS, + label: msg`Data Labels`, + Icon: IconChartBar, + isBoolean: true, + isInput: false, + dependsOn: undefined, + }; + + const result = isChartSettingDisabled( + itemWithUndefinedDependsOn, + '', + false, + ); + + expect(result).toBe(false); + }); + + it('should handle empty dependsOn array', () => { + const itemWithEmptyDependsOn: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS, + label: msg`Data Labels`, + Icon: IconChartBar, + isBoolean: true, + isInput: false, + dependsOn: [], + }; + + const result = isChartSettingDisabled(itemWithEmptyDependsOn, '', false); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isMinMaxRangeValid.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isMinMaxRangeValid.test.ts new file mode 100644 index 00000000000..d47b9dba4b6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/isMinMaxRangeValid.test.ts @@ -0,0 +1,117 @@ +import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { isMinMaxRangeValid } from '@/command-menu/pages/page-layout/utils/isMinMaxRangeValid'; +import { GraphType } from '~/generated/graphql'; + +describe('isMinMaxRangeValid', () => { + const mockConfiguration = { + __typename: 'BarChartConfiguration', + graphType: GraphType.VERTICAL_BAR, + rangeMin: 10, + rangeMax: 100, + } as ChartConfiguration; + + describe('MIN_RANGE validation', () => { + it('should be valid when new rangeMin is less than existing rangeMax', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + 50, + mockConfiguration, + ); + + expect(result).toBe(true); + }); + + it('should be valid when new rangeMin equals existing rangeMax', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + 100, + mockConfiguration, + ); + + expect(result).toBe(true); + }); + + it('should be invalid when new rangeMin is greater than existing rangeMax', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + 150, + mockConfiguration, + ); + + expect(result).toBe(false); + }); + }); + + describe('MAX_RANGE validation', () => { + it('should be valid when new rangeMax is greater than existing rangeMin', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + 50, + mockConfiguration, + ); + + expect(result).toBe(true); + }); + + it('should be valid when new rangeMax equals existing rangeMin', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + 10, + mockConfiguration, + ); + + expect(result).toBe(true); + }); + + it('should be invalid when new rangeMax is less than existing rangeMin', () => { + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + 5, + mockConfiguration, + ); + + expect(result).toBe(false); + }); + }); + describe('edge cases', () => { + it('should be valid when new rangeMin is negative and greater than existing rangeMax', () => { + const configWithNegatives = { + __typename: 'BarChartConfiguration', + graphType: GraphType.VERTICAL_BAR, + rangeMin: -100, + rangeMax: -10, + } as ChartConfiguration; + + const validMin = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + -50, + configWithNegatives, + ); + expect(validMin).toBe(true); + + const invalidMin = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + -5, + configWithNegatives, + ); + expect(invalidMin).toBe(false); + }); + + it('should be valid when new rangeMin is zero and greater than existing rangeMax', () => { + const configWithZero = { + __typename: 'BarChartConfiguration', + graphType: GraphType.VERTICAL_BAR, + rangeMin: 0, + rangeMax: 100, + } as ChartConfiguration; + + const result = isMinMaxRangeValid( + CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE, + 0, + configWithZero, + ); + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts index 9456287c340..1b67bfac399 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts @@ -7,6 +7,8 @@ import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/ import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; +import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMaxSetting'; +import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting'; @@ -32,6 +34,8 @@ export const getBarChartSettings = ( { ...DATA_DISPLAY_Y_SETTING, Icon: dataDisplayYIcon }, GROUP_BY_SETTING, SORT_BY_GROUP_BY_FIELD_SETTING, + RANGE_MIN_SETTING, + RANGE_MAX_SETTING, ]; const xAxisItems = isHorizontal ? secondaryAxisItems : primaryAxisItems; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId.ts new file mode 100644 index 00000000000..50928b789b8 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getConfigKeyFromSettingId.ts @@ -0,0 +1,14 @@ +import { + type CHART_CONFIGURATION_SETTING_IDS, + CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP, +} from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; + +export const getConfigKeyFromSettingId = ( + settingId: CHART_CONFIGURATION_SETTING_IDS, +): string => { + return settingId in CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP + ? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[ + settingId as keyof typeof CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP + ] + : settingId; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartSettingDisabled.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartSettingDisabled.ts new file mode 100644 index 00000000000..8df0820de6c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartSettingDisabled.ts @@ -0,0 +1,22 @@ +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { isNonEmptyString } from '@sniptt/guards'; + +export const isChartSettingDisabled = ( + item: ChartSettingsItem, + objectMetadataId: string, + isGroupByEnabled: boolean, +): boolean => { + const hasNoObjectMetadata = !isNonEmptyString(objectMetadataId); + const dependsOnSource = item?.dependsOn?.includes( + CHART_CONFIGURATION_SETTING_IDS.SOURCE, + ); + const dependsOnGroupBy = item?.dependsOn?.includes( + CHART_CONFIGURATION_SETTING_IDS.GROUP_BY, + ); + + return ( + (hasNoObjectMetadata && (dependsOnSource ?? false)) || + (!isGroupByEnabled && (dependsOnGroupBy ?? false)) + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isMinMaxRangeValid.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isMinMaxRangeValid.ts new file mode 100644 index 00000000000..4434dd2bf0f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isMinMaxRangeValid.ts @@ -0,0 +1,35 @@ +import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { isDefined } from 'twenty-shared/utils'; + +export const isMinMaxRangeValid = ( + settingId: + | CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE + | CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE, + newValue: number, + configuration: ChartConfiguration, +): boolean => { + if (!('rangeMax' in configuration || 'rangeMin' in configuration)) { + return false; + } + + if (settingId === CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE) { + if ( + isDefined(configuration.rangeMax) && + newValue > configuration.rangeMax + ) { + return false; + } + } + + if (settingId === CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE) { + if ( + isDefined(configuration.rangeMin) && + newValue < configuration.rangeMin + ) { + return false; + } + } + + return true; +}; 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 125a50d1197..f0149cfc9be 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 @@ -40,6 +40,8 @@ type GraphWidgetBarChartProps = { layout?: 'vertical' | 'horizontal'; groupMode?: 'grouped' | 'stacked'; seriesLabels?: Record; + rangeMin?: number; + rangeMax?: number; } & GraphValueFormatOptions; const StyledContainer = styled.div` @@ -65,6 +67,8 @@ export const GraphWidgetBarChart = ({ layout = 'vertical', groupMode = 'grouped', seriesLabels, + rangeMin, + rangeMax, displayType, decimals, prefix, @@ -180,7 +184,12 @@ export const GraphWidgetBarChart = ({ padding={0.3} groupMode={groupMode} layout={layout} - valueScale={{ type: 'linear' }} + valueScale={{ + type: 'linear', + min: rangeMin ?? 'auto', + max: rangeMax ?? 'auto', + clamp: true, + }} indexScale={{ type: 'band', round: true }} colors={(datum) => getBarChartColor(datum, barConfigs, theme)} defs={defs} 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 280c06b2680..c3eda43c1e6 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,6 +1,6 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader'; import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData'; -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import { type BarChartConfiguration, type PageLayoutWidget, @@ -34,17 +34,28 @@ export const GraphWidgetBarChartRenderer = ({ configuration: widget.configuration as BarChartConfiguration, }); - if (loading) { - return ; - } - const configuration = widget.configuration as BarChartConfiguration; const groupMode = configuration.groupMode === 'GROUPED' ? 'grouped' : 'stacked'; + const filterStateKey = useMemo( + () => + `${configuration.rangeMin ?? ''}-${configuration.rangeMax ?? ''}-${configuration.omitNullValues ?? ''}`, + [ + configuration.rangeMin, + configuration.rangeMax, + configuration.omitNullValues, + ], + ); + + if (loading) { + return ; + } + return ( }> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap index 6362cae8097..0632267999b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap @@ -6,14 +6,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr $groupBy: [PersonGroupByInput!] $filter: PersonFilterInput $orderBy: [PersonOrderByWithGroupByInput!] - $omitNullValues: Boolean $viewId: UUID ) { peopleGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy - omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues @@ -28,14 +26,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a $groupBy: [OpportunityGroupByInput!] $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] - $omitNullValues: Boolean $viewId: UUID ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy - omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues @@ -53,14 +49,12 @@ exports[`generateGroupByQuery should generate valid GraphQL query for single agg $groupBy: [OpportunityGroupByInput!] $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] - $omitNullValues: Boolean $viewId: UUID ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy - omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts new file mode 100644 index 00000000000..42c44667529 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/filterGroupByResults.test.ts @@ -0,0 +1,200 @@ +import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { filterGroupByResults } from '../filterGroupByResults'; + +describe('filterGroupByResults', () => { + const mockAggregateField = { + id: 'field-1', + name: 'amount', + type: FieldMetadataType.NUMBER, + label: 'Amount', + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + }; + + const mockObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [mockAggregateField], + } as any; + + const createMockResult = (value: number | null) => ({ + groupByDimensionValues: ['Group A'], + SUM_amount: value, + }); + + describe('rangeMin filtering', () => { + it('should filter out results below rangeMin', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1500), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMin: 1000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(2); + expect(filtered[0].SUM_amount).toBe(1500); + expect(filtered[1].SUM_amount).toBe(2500); + }); + + it('should include values equal to rangeMin', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1000), + createMockResult(1500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMin: 1000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(2); + expect(filtered[0].SUM_amount).toBe(1000); + expect(filtered[1].SUM_amount).toBe(1500); + }); + }); + + describe('rangeMax filtering', () => { + it('should filter out results above rangeMax', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1500), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMax: 2000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(2); + expect(filtered[0].SUM_amount).toBe(500); + expect(filtered[1].SUM_amount).toBe(1500); + }); + + it('should include values equal to rangeMax', () => { + const rawResults = [ + createMockResult(1500), + createMockResult(2000), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMax: 2000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(2); + expect(filtered[0].SUM_amount).toBe(1500); + expect(filtered[1].SUM_amount).toBe(2000); + }); + }); + + describe('range filtering', () => { + it('should keep only results within range', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1500), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMin: 1000, rangeMax: 2000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(1); + expect(filtered[0].SUM_amount).toBe(1500); + }); + + it('should include boundary values', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1000), + createMockResult(1500), + createMockResult(2000), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { rangeMin: 1000, rangeMax: 2000 }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toHaveLength(3); + expect(filtered[0].SUM_amount).toBe(1000); + expect(filtered[1].SUM_amount).toBe(1500); + expect(filtered[2].SUM_amount).toBe(2000); + }); + }); + + describe('no filters', () => { + it('should return all results when no filters are active', () => { + const rawResults = [ + createMockResult(500), + createMockResult(1500), + createMockResult(2500), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: {}, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toEqual(rawResults); + }); + + it('should return all results when omitNullValues is false', () => { + const rawResults = [ + createMockResult(null), + createMockResult(0), + createMockResult(100), + ]; + + const filtered = filterGroupByResults({ + rawResults, + filterOptions: { omitNullValues: false }, + aggregateField: mockAggregateField, + aggregateOperation: AggregateOperations.SUM, + aggregateOperationFromRawResult: 'SUM_amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(filtered).toEqual(rawResults); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts new file mode 100644 index 00000000000..849799f9f32 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/filterGroupByResults.ts @@ -0,0 +1,69 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; +import { isDefined } from 'twenty-shared/utils'; + +export type GroupByResultFilterOptions = { + rangeMin?: number | null; + rangeMax?: number | null; + omitNullValues?: boolean; +}; + +type FilterGroupByResultsParams = { + rawResults: GroupByRawResult[]; + filterOptions: GroupByResultFilterOptions; + aggregateField: FieldMetadataItem; + aggregateOperation: ExtendedAggregateOperations; + aggregateOperationFromRawResult: string; + objectMetadataItem: ObjectMetadataItem; +}; + +export const filterGroupByResults = ({ + rawResults, + filterOptions, + aggregateField, + aggregateOperation, + aggregateOperationFromRawResult, + objectMetadataItem, +}: FilterGroupByResultsParams): GroupByRawResult[] => { + const { rangeMin, rangeMax, omitNullValues } = filterOptions; + + const hasActiveFilters = + isDefined(rangeMin) || isDefined(rangeMax) || omitNullValues === true; + + if (!hasActiveFilters) { + return rawResults; + } + + return rawResults.filter((result) => { + const aggregateValue = computeAggregateValueFromGroupByResult({ + rawResult: result, + aggregateField, + aggregateOperation, + aggregateOperationFromRawResult, + objectMetadataItem, + }); + + if (omitNullValues === true && !isDefined(aggregateValue)) { + return false; + } + + if (omitNullValues === true && aggregateValue === 0) { + return false; + } + + if (typeof aggregateValue === 'number') { + if (isDefined(rangeMin) && aggregateValue < rangeMin) { + return false; + } + + if (isDefined(rangeMax) && aggregateValue > rangeMax) { + return false; + } + } + + return true; + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts index 0d077496135..62545b7c9e0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts @@ -20,14 +20,12 @@ export const generateGroupByQuery = ({ $groupBy: [${capitalizedSingular}GroupByInput!] $filter: ${capitalizedSingular}FilterInput $orderBy: [${capitalizedSingular}OrderByWithGroupByInput!] - $omitNullValues: Boolean $viewId: UUID ) { ${queryFieldName}( groupBy: $groupBy filter: $filter orderBy: $orderBy - omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts index b56ba18d5d4..d8d22c50952 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/generateGroupByQueryVariablesFromBarChartConfiguration.ts @@ -101,6 +101,5 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ return { groupBy, ...(orderBy.length > 0 && { orderBy }), - ...(barChartConfiguration.omitNullValues ? { omitNullValues: true } : {}), }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts index 31707a91052..6dc061125e7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts @@ -1,10 +1,12 @@ 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 BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData'; import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData'; @@ -105,6 +107,20 @@ export const transformGroupByDataToBarChartData = ({ }; } + 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; @@ -123,7 +139,7 @@ export const transformGroupByDataToBarChartData = ({ const baseResult = isDefined(groupByFieldY) ? transformTwoDimensionalGroupByToBarChartData({ - rawResults, + rawResults: filteredResults, groupByFieldX, groupByFieldY, aggregateField, @@ -133,7 +149,7 @@ export const transformGroupByDataToBarChartData = ({ primaryAxisSubFieldName, }) : transformOneDimensionalGroupByToBarChartData({ - rawResults, + rawResults: filteredResults, groupByFieldX, aggregateField, configuration, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index c478dfcdf2f..0152a279a39 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -29,7 +29,6 @@ import { } from 'src/engine/api/common/types/common-query-args.type'; import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; -import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; @@ -169,32 +168,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic } }); - if (processedArgs.omitNullValues) { - const aggregateFields = selectedFieldsResult.aggregate ?? {}; - - Object.values(aggregateFields).forEach((aggregationField) => { - const aggregateExpression = - ProcessAggregateHelper.getAggregateExpression( - aggregationField, - objectMetadataNameSingular, - ); - - if (aggregateExpression) { - queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); - - const isNumericReturningAggregate = - computeIsNumericReturningAggregate( - aggregationField.aggregateOperation, - aggregationField.fromFieldType, - ); - - if (isNumericReturningAggregate) { - queryBuilder.andHaving(`${aggregateExpression} != 0`); - } - } - }); - } - commonQueryParser.applyGroupByOrderToBuilder( queryBuilder, processedArgs.orderBy ?? [], diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts index b3013753970..9d6dcc8808f 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -49,7 +49,6 @@ export interface GroupByQueryArgs { filter?: ObjectRecordFilter; orderBy?: OrderByWithGroupBy; groupBy: ObjectRecordGroupBy; - omitNullValues?: boolean; viewId?: string; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts index 38486642bbd..8668cfad0cc 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts @@ -25,7 +25,6 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; @@ -137,33 +136,6 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver } }); - if (executionArgs.args.omitNullValues) { - const aggregateFields = - executionArgs.graphqlQuerySelectedFieldsResult.aggregate ?? {}; - - Object.values(aggregateFields).forEach((aggregationField) => { - const aggregateExpression = - ProcessAggregateHelper.getAggregateExpression( - aggregationField, - objectMetadataNameSingular, - ); - - if (aggregateExpression) { - queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); - - const isNumericReturningAggregate = - computeIsNumericReturningAggregate( - aggregationField.aggregateOperation, - aggregationField.fromFieldType, - ); - - if (isNumericReturningAggregate) { - queryBuilder.andHaving(`${aggregateExpression} != 0`); - } - } - }); - } - executionArgs.graphqlQueryParser.applyGroupByOrderToBuilder( queryBuilder, executionArgs.args.orderBy ?? [], diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 956ba8ba419..2715f081eac 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -63,7 +63,6 @@ export interface GroupByResolverArgs { groupBy: ObjectRecordGroupBy; viewId?: string; orderBy?: OrderByWithGroupBy; - omitNullValues?: boolean; } export interface UpdateOneResolverArgs< diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index 610a11fa21b..8de1853a429 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -177,10 +177,7 @@ export const getResolverArgs = ( isNullable: true, isArray: true, }, - omitNullValues: { - type: GraphQLBoolean, - isNullable: true, - }, + viewId: { type: UUIDScalarType, isNullable: true, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts index 660ae419a0a..2b1d125fdff 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts @@ -6,7 +6,6 @@ import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-qu import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util'; import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util'; import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util'; -import { parseOmitNullValuesRestRequest } from 'src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util'; import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util'; import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; @@ -28,14 +27,8 @@ export class RestApiGroupByHandler extends RestApiBaseHandler { objectMetadataMaps, } = await this.buildCommonOptions(request); - const { - filter, - orderBy, - viewId, - groupBy, - selectedFields, - omitNullValues, - } = this.parseRequestArgs(request); + const { filter, orderBy, viewId, groupBy, selectedFields } = + this.parseRequestArgs(request); return await this.commonGroupByQueryRunnerService.run({ args: { @@ -44,7 +37,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler { viewId, groupBy, selectedFields, - omitNullValues, }, authContext, objectMetadataMaps, @@ -61,7 +53,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler { const viewId = parseViewIdRestRequest(request); const groupBy = parseGroupByRestRequest(request); const aggregateFields = parseAggregateFieldsRestRequest(request); - const omitNullValues = parseOmitNullValuesRestRequest(request); const selectedFields = { ...aggregateFields, groupByDimensionValues: true }; return { @@ -70,7 +61,6 @@ export class RestApiGroupByHandler extends RestApiBaseHandler { viewId, groupBy, selectedFields, - omitNullValues, }; } } diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts index e352d443a73..a49a284e98a 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'crypto'; -import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util'; @@ -612,190 +611,4 @@ describe('group-by resolvers (integration)', () => { ); }); }); - - describe('omitNullValues', () => { - describe('numeric aggregates', () => { - const zeroGroupCompanyId1 = randomUUID(); - const zeroGroupCompanyId2 = randomUUID(); - const positiveGroupCompanyId = randomUUID(); - const zeroCity = 'ZeroCity'; - const positiveCity = 'PositiveCity'; - - beforeAll(async () => { - await makeGraphqlAPIRequest( - createOneOperationFactory({ - objectMetadataSingularName: 'company', - gqlFields: COMPANY_GQL_FIELDS, - data: { - id: zeroGroupCompanyId1, - name: 'Zero City One', - address: { addressCity: zeroCity }, - employees: 0, - }, - }), - ); - await makeGraphqlAPIRequest( - createOneOperationFactory({ - objectMetadataSingularName: 'company', - gqlFields: COMPANY_GQL_FIELDS, - data: { - id: zeroGroupCompanyId2, - name: 'Zero City Two', - address: { addressCity: zeroCity }, - employees: 0, - }, - }), - ); - await makeGraphqlAPIRequest( - createOneOperationFactory({ - objectMetadataSingularName: 'company', - gqlFields: COMPANY_GQL_FIELDS, - data: { - id: positiveGroupCompanyId, - name: 'Positive City One', - address: { addressCity: positiveCity }, - employees: 8, - }, - }), - ); - }); - - afterAll(async () => { - for (const id of [ - zeroGroupCompanyId1, - zeroGroupCompanyId2, - positiveGroupCompanyId, - ]) { - await makeGraphqlAPIRequest( - destroyOneOperationFactory({ - objectMetadataSingularName: 'company', - gqlFields: 'id', - recordId: id, - }), - ); - } - }); - - it('filters out groups with zero numeric aggregates when enabled', async () => { - const baseOperation = { - objectMetadataSingularName: 'company', - objectMetadataPluralName: 'companies', - groupBy: [{ address: { addressCity: true } }], - gqlFields: ` - sumEmployees - `, - }; - - const responseWithoutFilter = await makeGraphqlAPIRequest( - groupByOperationFactory(baseOperation), - ); - - expect(responseWithoutFilter.body.errors).toBeUndefined(); - - const groupsWithoutFilter = - responseWithoutFilter.body.data.companiesGroupBy; - - const zeroAggregateGroup = groupsWithoutFilter.find( - (group: any) => group.groupByDimensionValues?.[0] === zeroCity, - ); - - expect(zeroAggregateGroup).toBeDefined(); - expect(zeroAggregateGroup.sumEmployees).toBe(0); - - const responseWithFilter = await makeGraphqlAPIRequest( - groupByOperationFactory({ - ...baseOperation, - omitNullValues: true, - }), - ); - - expect(responseWithFilter.body.errors).toBeUndefined(); - - const groupsWithFilter = responseWithFilter.body.data.companiesGroupBy; - - expect( - groupsWithFilter.find( - (group: any) => group.groupByDimensionValues?.[0] === zeroCity, - ), - ).toBeUndefined(); - - const positiveAggregateGroup = groupsWithFilter.find( - (group: any) => group.groupByDimensionValues?.[0] === positiveCity, - ); - - expect(positiveAggregateGroup).toBeDefined(); - expect(positiveAggregateGroup.sumEmployees).toBeGreaterThan(0); - }); - }); - - describe('non-numeric aggregates', () => { - const dateCityPersonId1 = randomUUID(); - const dateCityPersonId2 = randomUUID(); - const dateCity = 'DateCity'; - - beforeAll(async () => { - await makeGraphqlAPIRequest( - createOneOperationFactory({ - objectMetadataSingularName: 'person', - gqlFields: PERSON_GQL_FIELDS, - data: { - id: dateCityPersonId1, - name: { firstName: 'Date', lastName: 'One' }, - city: dateCity, - createdAt: '2025-04-01T00:00:00.000Z', - }, - }), - ); - await makeGraphqlAPIRequest( - createOneOperationFactory({ - objectMetadataSingularName: 'person', - gqlFields: PERSON_GQL_FIELDS, - data: { - id: dateCityPersonId2, - name: { firstName: 'Date', lastName: 'Two' }, - city: dateCity, - createdAt: '2025-04-02T00:00:00.000Z', - }, - }), - ); - }); - - afterAll(async () => { - for (const id of [dateCityPersonId1, dateCityPersonId2]) { - await makeGraphqlAPIRequest( - destroyOneOperationFactory({ - objectMetadataSingularName: 'person', - gqlFields: 'id', - recordId: id, - }), - ); - } - }); - - it('does not throw error for date aggregates when enabled', async () => { - const response = await makeGraphqlAPIRequest( - groupByOperationFactory({ - objectMetadataSingularName: 'person', - objectMetadataPluralName: 'people', - groupBy: [{ city: true }], - gqlFields: ` - minCreatedAt - `, - omitNullValues: true, - }), - ); - - expect(response.body.errors).toBeUndefined(); - - const groups = response.body.data.peopleGroupBy; - - const dateCityGroup = groups.find( - (group: any) => group.groupByDimensionValues?.[0] === dateCity, - ); - - expect(dateCityGroup).toBeDefined(); - expect(dateCityGroup.minCreatedAt).toBe('2025-04-01T00:00:00.000Z'); - }); - }); - }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts index 1c4902a6ee9..573439b1c51 100644 --- a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts @@ -9,7 +9,6 @@ type GroupByOperationFactoryParams = { orderBy?: object[]; viewId?: string; gqlFields?: string; - omitNullValues?: boolean; }; export const groupByOperationFactory = ({ @@ -20,11 +19,10 @@ export const groupByOperationFactory = ({ orderBy = [], viewId, gqlFields, - omitNullValues, }: GroupByOperationFactoryParams) => ({ query: gql` - query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID, $omitNullValues: Boolean) { - ${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId, omitNullValues: $omitNullValues) { + query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID) { + ${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId) { ${gqlFields ? gqlFields : ''} groupByDimensionValues totalCount @@ -36,6 +34,5 @@ export const groupByOperationFactory = ({ filter, orderBy, ...(viewId && { viewId }), - omitNullValues: omitNullValues ?? null, }, }); diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index d29ae1a1af6..59a3a7cf7f7 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -234,6 +234,8 @@ export { IconMailCog, IconMailX, IconMap, + IconMathMax, + IconMathMin, IconMathXy, IconMaximize, IconMessage, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index b34e8e4095e..b18efa4c43c 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -297,6 +297,8 @@ export { IconMailCog, IconMailX, IconMap, + IconMathMax, + IconMathMin, IconMathXy, IconMaximize, IconMessage,