diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartColorSelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartColorSelectionDropdownContent.tsx index c17d65d3b4..7752bda00a 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartColorSelectionDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartColorSelectionDropdownContent.tsx @@ -54,7 +54,12 @@ export const ChartColorSelectionDropdownContent = () => { const configuration = widgetInEditMode.configuration as ChartConfiguration; - if (!('color' in configuration)) { + if ( + configuration.__typename !== 'BarChartConfiguration' && + configuration.__typename !== 'LineChartConfiguration' && + configuration.__typename !== 'GaugeChartConfiguration' && + configuration.__typename !== 'PieChartConfiguration' + ) { return null; } 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 5c641b2304..245597d559 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 @@ -44,6 +44,13 @@ export const useChartSettingsValues = ({ configuration.__typename === 'BarChartConfiguration' || configuration.__typename === 'LineChartConfiguration'; + const hasColorProperty = + isBarOrLineChart || + configuration.__typename === 'GaugeChartConfiguration' || + configuration.__typename === 'PieChartConfiguration'; + + const isPieChart = configuration.__typename === 'PieChartConfiguration'; + let groupByFieldXId: string | undefined; let groupByFieldYId: string | undefined; let groupBySubFieldNameX: CompositeFieldSubFieldName | undefined; @@ -147,7 +154,7 @@ export const useChartSettingsValues = ({ case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X: return groupBySubFieldNameXLabel ?? groupByFieldX?.label; case CHART_CONFIGURATION_SETTING_IDS.COLORS: - return 'color' in configuration && isDefined(configuration.color) + return hasColorProperty && isDefined(configuration.color) ? capitalize(configuration.color) : undefined; case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y: @@ -183,8 +190,7 @@ export const useChartSettingsValues = ({ case CHART_CONFIGURATION_SETTING_IDS.GROUP_BY: return groupByFieldY?.label; case CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME: - return 'axisNameDisplay' in configuration && - isDefined(configuration.axisNameDisplay) + return isBarOrLineChart && isDefined(configuration.axisNameDisplay) ? getChartAxisNameDisplayOptions(configuration.axisNameDisplay) : undefined; case CHART_CONFIGURATION_SETTING_IDS.PRIMARY_SORT_BY: @@ -202,32 +208,31 @@ export const useChartSettingsValues = ({ ? configuration.isStacked !== false : true; case CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES: - return 'omitNullValues' in configuration + return isBarOrLineChart ? (configuration.omitNullValues ?? false) : false; case CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE: - return 'rangeMin' in configuration + return isBarOrLineChart ? (configuration.rangeMin?.toString() ?? '') : ''; case CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE: - return 'rangeMax' in configuration + return isBarOrLineChart ? (configuration.rangeMax?.toString() ?? '') : ''; case CHART_CONFIGURATION_SETTING_IDS.DATE_GRANULARITY_X: - return 'primaryAxisDateGranularity' in configuration && + return isBarOrLineChart && isDefined(configuration.primaryAxisDateGranularity) ? getDateGranularityLabel(configuration.primaryAxisDateGranularity) : undefined; case CHART_CONFIGURATION_SETTING_IDS.DATE_GRANULARITY_Y: - return 'secondaryAxisGroupByDateGranularity' in configuration && + return isBarOrLineChart && isDefined(configuration.secondaryAxisGroupByDateGranularity) ? getDateGranularityLabel( configuration.secondaryAxisGroupByDateGranularity, ) : undefined; case CHART_CONFIGURATION_SETTING_IDS.DATE_GRANULARITY: - return 'dateGranularity' in configuration && - isDefined(configuration.dateGranularity) + return isPieChart && isDefined(configuration.dateGranularity) ? getDateGranularityLabel(configuration.dateGranularity) : undefined; default: diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts new file mode 100644 index 0000000000..24254a1dd2 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts @@ -0,0 +1,11 @@ +import { + type GraphOrderBy, + type ObjectRecordGroupByDateGranularity, +} from '~/generated/graphql'; + +export type BarLineChartConvertibleFields = { + primaryAxisGroupByFieldMetadataId?: string; + primaryAxisGroupBySubFieldName?: string | null; + primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null; + primaryAxisOrderBy?: GraphOrderBy | null; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts new file mode 100644 index 0000000000..097cb61950 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts @@ -0,0 +1,11 @@ +import { + type GraphOrderBy, + type ObjectRecordGroupByDateGranularity, +} from '~/generated/graphql'; + +export type PieChartConvertibleFields = { + groupByFieldMetadataId?: string; + groupBySubFieldName?: string | null; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; + orderBy?: GraphOrderBy | null; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts index 5a7bcf2b51..cd6934c6fc 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts @@ -1,3 +1,4 @@ +import { type PieChartConvertibleFields } from '@/command-menu/pages/page-layout/types/PieChartConvertibleFields'; import { type BarChartConfiguration, type LineChartConfiguration, @@ -5,26 +6,18 @@ import { export const convertBarOrLineChartConfigToPieChart = ( configuration: BarChartConfiguration | LineChartConfiguration, -): Record => { - const configToUpdate: Record = {}; - - if ('primaryAxisGroupByFieldMetadataId' in configuration) { - configToUpdate.groupByFieldMetadataId = - configuration.primaryAxisGroupByFieldMetadataId; +): PieChartConvertibleFields => { + if ( + configuration.__typename !== 'BarChartConfiguration' && + configuration.__typename !== 'LineChartConfiguration' + ) { + return {}; } - if ('primaryAxisGroupBySubFieldName' in configuration) { - configToUpdate.groupBySubFieldName = - configuration.primaryAxisGroupBySubFieldName; - } - - if ('primaryAxisDateGranularity' in configuration) { - configToUpdate.dateGranularity = configuration.primaryAxisDateGranularity; - } - - if ('primaryAxisOrderBy' in configuration) { - configToUpdate.orderBy = configuration.primaryAxisOrderBy; - } - - return configToUpdate; + return { + groupByFieldMetadataId: configuration.primaryAxisGroupByFieldMetadataId, + groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName, + dateGranularity: configuration.primaryAxisDateGranularity, + orderBy: configuration.primaryAxisOrderBy, + }; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts index afe858435d..b769c5efde 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts @@ -1,27 +1,17 @@ +import { type BarLineChartConvertibleFields } from '@/command-menu/pages/page-layout/types/BarLineChartConvertibleFields'; import { type PieChartConfiguration } from '~/generated/graphql'; export const convertPieChartConfigToBarOrLineChart = ( configuration: PieChartConfiguration, -): Record => { - const configToUpdate: Record = {}; - - if ('groupByFieldMetadataId' in configuration) { - configToUpdate.primaryAxisGroupByFieldMetadataId = - configuration.groupByFieldMetadataId; +): BarLineChartConvertibleFields => { + if (configuration.__typename !== 'PieChartConfiguration') { + return {}; } - if ('groupBySubFieldName' in configuration) { - configToUpdate.primaryAxisGroupBySubFieldName = - configuration.groupBySubFieldName; - } - - if ('dateGranularity' in configuration) { - configToUpdate.primaryAxisDateGranularity = configuration.dateGranularity; - } - - if ('orderBy' in configuration) { - configToUpdate.primaryAxisOrderBy = configuration.orderBy; - } - - return configToUpdate; + return { + primaryAxisGroupByFieldMetadataId: configuration.groupByFieldMetadataId, + primaryAxisGroupBySubFieldName: configuration.groupBySubFieldName, + primaryAxisDateGranularity: configuration.dateGranularity, + primaryAxisOrderBy: configuration.orderBy, + }; }; 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 index 3c3961f8cc..23ce015d54 100644 --- 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 @@ -9,9 +9,16 @@ export const isMinMaxRangeValid = ( newValue: number, configuration: ChartConfiguration, ): boolean => { + const isBarOrLineChart = + configuration.__typename === 'BarChartConfiguration' || + configuration.__typename === 'LineChartConfiguration'; + + if (!isBarOrLineChart) { + return true; + } + if (settingId === CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE) { if ( - 'rangeMax' in configuration && isDefined(configuration.rangeMax) && newValue > configuration.rangeMax ) { @@ -21,7 +28,6 @@ export const isMinMaxRangeValid = ( if (settingId === CHART_CONFIGURATION_SETTING_IDS.MAX_RANGE) { if ( - 'rangeMin' in configuration && isDefined(configuration.rangeMin) && newValue < configuration.rangeMin ) { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx index 7f234d41ba..8de8f85e73 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx @@ -2,12 +2,13 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { useGraphPieChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData'; import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; +import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { lazy, Suspense } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { AppPath } from 'twenty-shared/types'; -import { getAppPath, isDefined } from 'twenty-shared/utils'; +import { getAppPath } from 'twenty-shared/utils'; import { type PageLayoutWidget, type PieChartConfiguration, @@ -31,6 +32,7 @@ export const GraphWidgetPieChartRenderer = ({ loading, hasTooManyGroups, objectMetadataItem, + formattedToRawLookup, showDataLabels, } = useGraphPieChartWidgetData({ objectMetadataItemId: widget.objectMetadataId, @@ -38,6 +40,7 @@ export const GraphWidgetPieChartRenderer = ({ }); const navigate = useNavigate(); + const configuration = widget.configuration as PieChartConfiguration; const indexViewId = useRecoilValue( coreIndexViewIdFromObjectMetadataItemFamilySelector({ @@ -45,16 +48,28 @@ export const GraphWidgetPieChartRenderer = ({ }), ); - const handleSliceClick = (_datum: PieChartDataItem) => { - return navigate( - getAppPath( - AppPath.RecordIndexPage, - { - objectNamePlural: objectMetadataItem.namePlural, - }, - isDefined(indexViewId) ? { viewId: indexViewId } : undefined, - ), + const handleSliceClick = (datum: PieChartDataItem) => { + const rawValue = formattedToRawLookup.get(datum.id) ?? null; + + const drilldownQueryParams = buildChartDrilldownQueryParams({ + objectMetadataItem, + configuration, + clickedData: { + primaryBucketRawValue: rawValue, + }, + viewId: indexViewId, + timezone: configuration.timezone ?? undefined, + }); + + const url = getAppPath( + AppPath.RecordIndexPage, + { + objectNamePlural: objectMetadataItem.namePlural, + }, + Object.fromEntries(drilldownQueryParams), ); + + return navigate(url); }; if (loading) { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts index 9df1a4e08d..346cba7bcf 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts @@ -4,6 +4,7 @@ import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { useMemo } from 'react'; import { type PieChartConfiguration } from '~/generated/graphql'; @@ -18,6 +19,7 @@ type UseGraphPieChartWidgetDataResult = { error?: Error; hasTooManyGroups: boolean; objectMetadataItem: ObjectMetadataItem; + formattedToRawLookup: Map; showDataLabels: boolean; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts index 328e8b64a4..a5dc279582 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts @@ -6,8 +6,10 @@ import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; -import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { isDefined } from 'twenty-shared/utils'; import { type PieChartConfiguration } from '~/generated/graphql'; @@ -21,11 +23,13 @@ type TransformGroupByDataToPieChartDataParams = { type TransformGroupByDataToPieChartDataResult = { data: PieChartDataItem[]; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; const EMPTY_PIE_CHART_RESULT: TransformGroupByDataToPieChartDataResult = { data: [], hasTooManyGroups: false, + formattedToRawLookup: new Map(), }; export const transformGroupByDataToPieChartData = ({ @@ -65,17 +69,18 @@ export const transformGroupByDataToPieChartData = ({ PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, ); - const data: PieChartDataItem[] = limitedResults.map((result) => { - const dimensionValues = result.groupByDimensionValues; + const formattedValues = formatPrimaryDimensionValues({ + groupByRawResults: limitedResults, + primaryAxisGroupByField: groupByField, + primaryAxisDateGranularity: configuration.dateGranularity ?? undefined, + primaryAxisGroupBySubFieldName: + configuration.groupBySubFieldName ?? undefined, + }); - const label = isDefined(dimensionValues?.[0]) - ? formatDimensionValue({ - value: dimensionValues[0], - fieldMetadata: groupByField, - dateGranularity: configuration.dateGranularity ?? undefined, - subFieldName: configuration.groupBySubFieldName ?? undefined, - }) - : ''; + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); + + const data: PieChartDataItem[] = limitedResults.map((result, index) => { + const label = formattedValues[index]?.formattedPrimaryDimensionValue ?? ''; const value = computeAggregateValueFromGroupByResult({ rawResult: result, @@ -97,5 +102,6 @@ export const transformGroupByDataToPieChartData = ({ return { data, hasTooManyGroups: rawResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, + formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts index 15a7cf8eae..7fbbc08e33 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts @@ -3,11 +3,15 @@ import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDim import { type BarChartConfiguration, type LineChartConfiguration, + type PieChartConfiguration, } from '~/generated/graphql'; export type BuildChartDrilldownQueryParamsInput = { objectMetadataItem: ObjectMetadataItem; - configuration: BarChartConfiguration | LineChartConfiguration; + configuration: + | BarChartConfiguration + | LineChartConfiguration + | PieChartConfiguration; clickedData: { primaryBucketRawValue: RawDimensionValue; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartFieldOrderBy.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartFieldOrderBy.test.ts new file mode 100644 index 0000000000..50952b6b85 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartFieldOrderBy.test.ts @@ -0,0 +1,111 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { buildSortsForChartFieldOrderBy } from '@/page-layout/widgets/graph/utils/buildSortsForChartFieldOrderBy'; +import { type NormalizedChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { GraphOrderBy } from '~/generated/graphql'; + +describe('buildSortsForChartFieldOrderBy', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + id: 'field-status', + name: 'status', + type: FieldMetadataType.SELECT, + label: 'Status', + }, + { + id: 'field-createdAt', + name: 'createdAt', + type: FieldMetadataType.DATE_TIME, + label: 'Created At', + }, + { + id: 'field-address', + name: 'address', + type: FieldMetadataType.ADDRESS, + label: 'Address', + }, + ], + } as ObjectMetadataItem; + + it('should return ASC sort for FIELD_ASC orderBy', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'field-createdAt', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.FIELD_ASC, + }; + + const result = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ fieldName: 'createdAt', direction: 'ASC' }); + }); + + it('should return DESC sort for FIELD_DESC orderBy', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'field-status', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.FIELD_DESC, + }; + + const result = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ fieldName: 'status', direction: 'DESC' }); + }); + + it('should include subfield for composite fields', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'field-address', + groupBySubFieldName: 'addressCity', + orderBy: GraphOrderBy.FIELD_ASC, + }; + + const result = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ + fieldName: 'address.addressCity', + direction: 'ASC', + }); + }); + + it('should handle composite field without subfield', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'field-address', + groupBySubFieldName: null, + orderBy: GraphOrderBy.FIELD_ASC, + }; + + const result = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ fieldName: 'address', direction: 'ASC' }); + }); + + it('should return null when groupBy field not found', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'non-existent', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.FIELD_ASC, + }; + + const result = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartValueOrderBy.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartValueOrderBy.test.ts new file mode 100644 index 0000000000..e85599b214 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsForChartValueOrderBy.test.ts @@ -0,0 +1,69 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { buildSortsForChartValueOrderBy } from '@/page-layout/widgets/graph/utils/buildSortsForChartValueOrderBy'; +import { type NormalizedChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { GraphOrderBy } from '~/generated/graphql'; + +describe('buildSortsForChartValueOrderBy', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + id: 'field-amount', + name: 'amount', + type: FieldMetadataType.NUMBER, + label: 'Amount', + }, + ], + } as ObjectMetadataItem; + + it('should return ASC sort for VALUE_ASC orderBy', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'some-field', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.VALUE_ASC, + }; + + const result = buildSortsForChartValueOrderBy({ + normalizedFields, + aggregateFieldMetadataId: 'field-amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ fieldName: 'amount', direction: 'ASC' }); + }); + + it('should return DESC sort for VALUE_DESC orderBy', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'some-field', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.VALUE_DESC, + }; + + const result = buildSortsForChartValueOrderBy({ + normalizedFields, + aggregateFieldMetadataId: 'field-amount', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual({ fieldName: 'amount', direction: 'DESC' }); + }); + + it('should return null when aggregate field not found', () => { + const normalizedFields: NormalizedChartConfigurationFields = { + groupByFieldMetadataId: 'some-field', + groupBySubFieldName: undefined, + orderBy: GraphOrderBy.VALUE_ASC, + }; + + const result = buildSortsForChartValueOrderBy({ + normalizedFields, + aggregateFieldMetadataId: 'non-existent', + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsFromChartConfig.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsFromChartConfig.test.ts new file mode 100644 index 0000000000..6187cd76e9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildSortsFromChartConfig.test.ts @@ -0,0 +1,120 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { buildSortsFromChartConfig } from '@/page-layout/widgets/graph/utils/buildSortsFromChartConfig'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { + type BarChartConfiguration, + type PieChartConfiguration, + GraphOrderBy, +} from '~/generated/graphql'; + +describe('buildSortsFromChartConfig', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'obj-1', + nameSingular: 'opportunity', + namePlural: 'opportunities', + fields: [ + { + id: 'field-status', + name: 'status', + type: FieldMetadataType.SELECT, + label: 'Status', + }, + { + id: 'field-amount', + name: 'amount', + type: FieldMetadataType.NUMBER, + label: 'Amount', + }, + { + id: 'field-createdAt', + name: 'createdAt', + type: FieldMetadataType.DATE_TIME, + label: 'Created At', + }, + ], + } as ObjectMetadataItem; + + describe('integration', () => { + it('should handle field-based sorting end-to-end for Bar charts', () => { + const config = { + __typename: 'BarChartConfiguration', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + primaryAxisGroupByFieldMetadataId: 'field-createdAt', + aggregateFieldMetadataId: 'field-amount', + } as BarChartConfiguration; + + const result = buildSortsFromChartConfig({ + configuration: config, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual([{ fieldName: 'createdAt', direction: 'ASC' }]); + }); + + it('should handle value-based sorting end-to-end for Pie charts', () => { + const config = { + __typename: 'PieChartConfiguration', + orderBy: GraphOrderBy.VALUE_DESC, + groupByFieldMetadataId: 'field-status', + aggregateFieldMetadataId: 'field-amount', + } as PieChartConfiguration; + + const result = buildSortsFromChartConfig({ + configuration: config, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual([{ fieldName: 'amount', direction: 'DESC' }]); + }); + }); + + describe('edge cases', () => { + it('should return empty array when orderBy is undefined', () => { + const config = { + __typename: 'BarChartConfiguration', + primaryAxisOrderBy: undefined, + primaryAxisGroupByFieldMetadataId: 'field-createdAt', + aggregateFieldMetadataId: 'field-amount', + } as BarChartConfiguration; + + const result = buildSortsFromChartConfig({ + configuration: config, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when orderBy is null', () => { + const config = { + __typename: 'PieChartConfiguration', + orderBy: null, + groupByFieldMetadataId: 'field-status', + aggregateFieldMetadataId: 'field-amount', + } as PieChartConfiguration; + + const result = buildSortsFromChartConfig({ + configuration: config, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when field not found', () => { + const config = { + __typename: 'BarChartConfiguration', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + primaryAxisGroupByFieldMetadataId: 'non-existent', + aggregateFieldMetadataId: 'field-amount', + } as BarChartConfiguration; + + const result = buildSortsFromChartConfig({ + configuration: config, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/normalizeChartConfigurationFields.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/normalizeChartConfigurationFields.test.ts new file mode 100644 index 0000000000..8ee27b6270 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/normalizeChartConfigurationFields.test.ts @@ -0,0 +1,135 @@ +import { + AggregateOperations, + GraphOrderBy, + GraphType, + ObjectRecordGroupByDateGranularity, +} from '~/generated-metadata/graphql'; +import { normalizeChartConfigurationFields } from '../normalizeChartConfigurationFields'; + +describe('normalizeChartConfigurationFields', () => { + describe('Bar and Line charts (with primaryAxis prefix)', () => { + it('should extract fields from Bar chart configuration', () => { + const barConfig = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'field-123', + primaryAxisGroupBySubFieldName: 'subField', + primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH, + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + aggregateFieldMetadataId: 'aggregate-456', + aggregateOperation: AggregateOperations.SUM, + graphType: GraphType.VERTICAL_BAR, + } as any; + + const result = normalizeChartConfigurationFields(barConfig); + + expect(result.groupByFieldMetadataId).toBe('field-123'); + expect(result.groupBySubFieldName).toBe('subField'); + expect(result.dateGranularity).toBe( + ObjectRecordGroupByDateGranularity.MONTH, + ); + expect(result.orderBy).toBe(GraphOrderBy.FIELD_ASC); + }); + + it('should extract fields from Line chart configuration', () => { + const lineConfig = { + __typename: 'LineChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'field-789', + primaryAxisOrderBy: GraphOrderBy.VALUE_DESC, + aggregateFieldMetadataId: 'aggregate-012', + aggregateOperation: AggregateOperations.COUNT, + graphType: GraphType.LINE, + } as any; + + const result = normalizeChartConfigurationFields(lineConfig); + + expect(result.groupByFieldMetadataId).toBe('field-789'); + expect(result.orderBy).toBe(GraphOrderBy.VALUE_DESC); + expect(result.groupBySubFieldName).toBeUndefined(); + expect(result.dateGranularity).toBeUndefined(); + }); + }); + + describe('Pie charts (without prefix)', () => { + it('should extract fields from Pie chart configuration', () => { + const pieConfig = { + __typename: 'PieChartConfiguration', + groupByFieldMetadataId: 'pie-field', + groupBySubFieldName: 'pieSubField', + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + orderBy: GraphOrderBy.FIELD_DESC, + aggregateFieldMetadataId: 'pie-aggregate', + aggregateOperation: AggregateOperations.AVG, + graphType: GraphType.PIE, + } as any; + + const result = normalizeChartConfigurationFields(pieConfig); + + expect(result.groupByFieldMetadataId).toBe('pie-field'); + expect(result.groupBySubFieldName).toBe('pieSubField'); + expect(result.dateGranularity).toBe( + ObjectRecordGroupByDateGranularity.DAY, + ); + expect(result.orderBy).toBe(GraphOrderBy.FIELD_DESC); + }); + + it('should handle minimal Pie chart configuration', () => { + const pieConfig = { + __typename: 'PieChartConfiguration', + groupByFieldMetadataId: 'minimal-field', + aggregateFieldMetadataId: 'minimal-aggregate', + aggregateOperation: AggregateOperations.COUNT, + graphType: GraphType.PIE, + } as any; + + const result = normalizeChartConfigurationFields(pieConfig); + + expect(result.groupByFieldMetadataId).toBe('minimal-field'); + expect(result.groupBySubFieldName).toBeUndefined(); + expect(result.dateGranularity).toBeUndefined(); + expect(result.orderBy).toBeUndefined(); + }); + }); + + describe('Edge cases', () => { + it('should return empty object for configuration without recognized fields', () => { + const unknownConfig = { + someOtherField: 'value', + aggregateFieldMetadataId: 'ignored', + } as any; + + const result = normalizeChartConfigurationFields(unknownConfig); + + expect(result).toEqual({}); + }); + + it('should use __typename to determine fields even when both patterns exist', () => { + const mixedConfig = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'primary-field', + groupByFieldMetadataId: 'fallback-field', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + orderBy: GraphOrderBy.FIELD_DESC, + } as any; + + const result = normalizeChartConfigurationFields(mixedConfig); + + expect(result.groupByFieldMetadataId).toBe('primary-field'); + expect(result.orderBy).toBe(GraphOrderBy.FIELD_ASC); + }); + + it('should extract Pie fields when __typename is PieChartConfiguration even if primaryAxis fields exist', () => { + const mixedConfig = { + __typename: 'PieChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'primary-field', + groupByFieldMetadataId: 'pie-field', + primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, + orderBy: GraphOrderBy.FIELD_DESC, + } as any; + + const result = normalizeChartConfigurationFields(mixedConfig); + + expect(result.groupByFieldMetadataId).toBe('pie-field'); + expect(result.orderBy).toBe(GraphOrderBy.FIELD_DESC); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts index 4c7f73a0fe..c31a073ba9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts @@ -1,6 +1,8 @@ import { type BuildChartDrilldownQueryParamsInput } from '@/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput'; import { buildFilterFromChartBucket } from '@/page-layout/widgets/graph/utils/buildFilterFromChartBucket'; import { buildFilterQueryParams } from '@/page-layout/widgets/graph/utils/buildFilterQueryParams'; +import { buildSortsFromChartConfig } from '@/page-layout/widgets/graph/utils/buildSortsFromChartConfig'; +import { normalizeChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; import { isDefined } from 'twenty-shared/utils'; export const buildChartDrilldownQueryParams = ({ @@ -24,16 +26,21 @@ export const buildChartDrilldownQueryParams = ({ }); } - const primaryField = objectMetadataItem.fields.find( - (field) => field.id === configuration.primaryAxisGroupByFieldMetadataId, - ); + const { groupByFieldMetadataId, dateGranularity, groupBySubFieldName } = + normalizeChartConfigurationFields(configuration); + + const primaryField = isDefined(groupByFieldMetadataId) + ? objectMetadataItem.fields.find( + (field) => field.id === groupByFieldMetadataId, + ) + : undefined; if (isDefined(primaryField)) { const primaryFilters = buildFilterFromChartBucket({ fieldMetadataItem: primaryField, bucketRawValue: clickedData.primaryBucketRawValue, - dateGranularity: configuration.primaryAxisDateGranularity, - subFieldName: configuration.primaryAxisGroupBySubFieldName, + dateGranularity, + subFieldName: groupBySubFieldName, timezone, }); @@ -45,6 +52,15 @@ export const buildChartDrilldownQueryParams = ({ }); } + const sorts = buildSortsFromChartConfig({ + configuration, + objectMetadataItem, + }); + + sorts.forEach((sort) => { + drilldownQueryParams.append(`sort[${sort.fieldName}]`, sort.direction); + }); + if (isDefined(viewId)) { drilldownQueryParams.set('viewId', viewId); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartFieldOrderBy.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartFieldOrderBy.ts new file mode 100644 index 0000000000..472162cdae --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartFieldOrderBy.ts @@ -0,0 +1,49 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type NormalizedChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { GraphOrderBy } from '~/generated/graphql'; + +type ChartSort = { + fieldName: string; + direction: 'ASC' | 'DESC'; +}; + +type BuildSortsForChartFieldOrderByParams = { + normalizedFields: NormalizedChartConfigurationFields; + objectMetadataItem: ObjectMetadataItem; +}; + +export const buildSortsForChartFieldOrderBy = ({ + normalizedFields, + objectMetadataItem, +}: BuildSortsForChartFieldOrderByParams): ChartSort | null => { + const { groupByFieldMetadataId, groupBySubFieldName, orderBy } = + normalizedFields; + + if ( + orderBy !== GraphOrderBy.FIELD_ASC && + orderBy !== GraphOrderBy.FIELD_DESC + ) { + return null; + } + + const primaryField = groupByFieldMetadataId + ? objectMetadataItem.fields.find( + (field) => field.id === groupByFieldMetadataId, + ) + : undefined; + + if (!isDefined(primaryField)) { + return null; + } + + const fieldName = isNonEmptyString(groupBySubFieldName) + ? `${primaryField.name}.${groupBySubFieldName}` + : primaryField.name; + + return { + fieldName, + direction: orderBy === GraphOrderBy.FIELD_ASC ? 'ASC' : 'DESC', + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartValueOrderBy.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartValueOrderBy.ts new file mode 100644 index 0000000000..004356d98a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsForChartValueOrderBy.ts @@ -0,0 +1,43 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type NormalizedChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; +import { isDefined } from 'twenty-shared/utils'; +import { GraphOrderBy } from '~/generated/graphql'; + +type ChartSort = { + fieldName: string; + direction: 'ASC' | 'DESC'; +}; + +type BuildSortsForChartValueOrderByParams = { + normalizedFields: NormalizedChartConfigurationFields; + aggregateFieldMetadataId: string; + objectMetadataItem: ObjectMetadataItem; +}; + +export const buildSortsForChartValueOrderBy = ({ + normalizedFields, + aggregateFieldMetadataId, + objectMetadataItem, +}: BuildSortsForChartValueOrderByParams): ChartSort | null => { + const { orderBy } = normalizedFields; + + if ( + orderBy !== GraphOrderBy.VALUE_ASC && + orderBy !== GraphOrderBy.VALUE_DESC + ) { + return null; + } + + const aggregateField = objectMetadataItem.fields.find( + (field) => field.id === aggregateFieldMetadataId, + ); + + if (!isDefined(aggregateField)) { + return null; + } + + return { + fieldName: aggregateField.name, + direction: orderBy === GraphOrderBy.VALUE_ASC ? 'ASC' : 'DESC', + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsFromChartConfig.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsFromChartConfig.ts new file mode 100644 index 0000000000..32e7bb6e68 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildSortsFromChartConfig.ts @@ -0,0 +1,51 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { buildSortsForChartFieldOrderBy } from '@/page-layout/widgets/graph/utils/buildSortsForChartFieldOrderBy'; +import { buildSortsForChartValueOrderBy } from '@/page-layout/widgets/graph/utils/buildSortsForChartValueOrderBy'; +import { normalizeChartConfigurationFields } from '@/page-layout/widgets/graph/utils/normalizeChartConfigurationFields'; +import { isDefined } from 'twenty-shared/utils'; +import { + type BarChartConfiguration, + type LineChartConfiguration, + type PieChartConfiguration, +} from '~/generated/graphql'; + +type ChartSort = { + fieldName: string; + direction: 'ASC' | 'DESC'; +}; + +type BuildSortsFromChartConfigParams = { + configuration: + | BarChartConfiguration + | LineChartConfiguration + | PieChartConfiguration; + objectMetadataItem: ObjectMetadataItem; +}; + +export const buildSortsFromChartConfig = ({ + configuration, + objectMetadataItem, +}: BuildSortsFromChartConfigParams): ChartSort[] => { + const normalizedFields = normalizeChartConfigurationFields(configuration); + + const fieldSort = buildSortsForChartFieldOrderBy({ + normalizedFields, + objectMetadataItem, + }); + + if (isDefined(fieldSort)) { + return [fieldSort]; + } + + const valueSort = buildSortsForChartValueOrderBy({ + normalizedFields, + aggregateFieldMetadataId: configuration.aggregateFieldMetadataId, + objectMetadataItem, + }); + + if (isDefined(valueSort)) { + return [valueSort]; + } + + return []; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/normalizeChartConfigurationFields.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/normalizeChartConfigurationFields.ts new file mode 100644 index 0000000000..6ccfaa1959 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/normalizeChartConfigurationFields.ts @@ -0,0 +1,44 @@ +import { + type BarChartConfiguration, + type GraphOrderBy, + type LineChartConfiguration, + type ObjectRecordGroupByDateGranularity, + type PieChartConfiguration, +} from '~/generated-metadata/graphql'; + +export type NormalizedChartConfigurationFields = { + groupByFieldMetadataId?: string; + groupBySubFieldName?: string | null; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; + orderBy?: GraphOrderBy | null; +}; + +export const normalizeChartConfigurationFields = ( + configuration: + | BarChartConfiguration + | LineChartConfiguration + | PieChartConfiguration, +): NormalizedChartConfigurationFields => { + if ( + configuration.__typename === 'BarChartConfiguration' || + configuration.__typename === 'LineChartConfiguration' + ) { + return { + groupByFieldMetadataId: configuration.primaryAxisGroupByFieldMetadataId, + groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName, + dateGranularity: configuration.primaryAxisDateGranularity, + orderBy: configuration.primaryAxisOrderBy, + }; + } + + if (configuration.__typename === 'PieChartConfiguration') { + return { + groupByFieldMetadataId: configuration.groupByFieldMetadataId, + groupBySubFieldName: configuration.groupBySubFieldName, + dateGranularity: configuration.dateGranularity, + orderBy: configuration.orderBy, + }; + } + + return {}; +}; diff --git a/packages/twenty-front/src/modules/views/components/QueryParamsCleanupEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsCleanupEffect.tsx new file mode 100644 index 0000000000..4071745c11 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/QueryParamsCleanupEffect.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { useHasFiltersInQueryParams } from '@/views/hooks/internal/useHasFiltersInQueryParams'; +import { useSortsFromQueryParams } from '@/views/hooks/internal/useSortsFromQueryParams'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; + +export const QueryParamsCleanupEffect = () => { + const { hasFiltersQueryParams } = useHasFiltersInQueryParams(); + const { hasSortsQueryParams, objectMetadataItem } = useSortsFromQueryParams(); + + const { currentView } = useGetCurrentViewOnly(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const [hasCleanedQueryParams, setHasCleanedQueryParams] = useState(false); + + const currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem = + currentView?.objectMetadataId !== objectMetadataItem.id; + + useEffect(() => { + if ( + currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem || + hasCleanedQueryParams + ) { + return; + } + + if (!hasFiltersQueryParams && !hasSortsQueryParams) { + return; + } + + const newParams = new URLSearchParams(searchParams); + + Array.from(newParams.keys()).forEach((key) => { + if ( + key.startsWith('filter[') || + key.startsWith('filterGroup[') || + key.startsWith('sort[') + ) { + newParams.delete(key); + } + }); + + setSearchParams(newParams, { replace: true }); + setHasCleanedQueryParams(true); + }, [ + currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem, + hasFiltersQueryParams, + hasSortsQueryParams, + hasCleanedQueryParams, + searchParams, + setSearchParams, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/views/components/QueryParamsSortsEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsSortsEffect.tsx new file mode 100644 index 0000000000..38e58fc965 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/QueryParamsSortsEffect.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; + +import { useSortsFromQueryParams } from '@/views/hooks/internal/useSortsFromQueryParams'; +import { useApplyViewSortsToCurrentRecordSorts } from '@/views/hooks/useApplyViewSortsToCurrentRecordSorts'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; + +export const QueryParamsSortsEffect = () => { + const { hasSortsQueryParams, getSortsFromQueryParams, objectMetadataItem } = + useSortsFromQueryParams(); + + const { currentView } = useGetCurrentViewOnly(); + + const { applyViewSortsToCurrentRecordSorts } = + useApplyViewSortsToCurrentRecordSorts(); + + const currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem = + currentView?.objectMetadataId !== objectMetadataItem.id; + + useEffect(() => { + if ( + !hasSortsQueryParams || + currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem + ) { + return; + } + + const sortsFromParams = getSortsFromQueryParams(); + if (sortsFromParams.length > 0) { + const viewSorts = sortsFromParams.map((sort) => ({ + ...sort, + viewId: currentView?.id ?? '', + })); + + applyViewSortsToCurrentRecordSorts(viewSorts); + } + }, [ + hasSortsQueryParams, + getSortsFromQueryParams, + applyViewSortsToCurrentRecordSorts, + currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem, + currentView?.id, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index f6ba1af7f8..287a9d1296 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -5,6 +5,7 @@ import { ObjectSortDropdownButton } from '@/object-record/object-sort-dropdown/c import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { TopBar } from '@/ui/layout/top-bar/components/TopBar'; import { QueryParamsFiltersEffect } from '@/views/components/QueryParamsFiltersEffect'; +import { QueryParamsSortsEffect } from '@/views/components/QueryParamsSortsEffect'; import { ViewBarPageTitle } from '@/views/components/ViewBarPageTitle'; import { ViewBarSkeletonLoader } from '@/views/components/ViewBarSkeletonLoader'; import { ViewPickerDropdown } from '@/views/view-picker/components/ViewPickerDropdown'; @@ -12,6 +13,7 @@ import { ViewPickerDropdown } from '@/views/view-picker/components/ViewPickerDro import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; import { VIEW_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ViewSortDropdownId'; import { ObjectSortDropdownComponentInstanceContext } from '@/object-record/object-sort-dropdown/states/context/ObjectSortDropdownComponentInstanceContext'; +import { QueryParamsCleanupEffect } from '@/views/components/QueryParamsCleanupEffect'; import { ViewBarAnyFieldFilterEffect } from '@/views/components/ViewBarAnyFieldFilterEffect'; import { ViewBarFilterDropdown } from '@/views/components/ViewBarFilterDropdown'; import { ViewBarRecordFieldEffect } from '@/views/components/ViewBarRecordFieldEffect'; @@ -50,6 +52,8 @@ export const ViewBar = ({ + + { + const [searchParams] = useSearchParams(); + const { objectNamePlural = '' } = useParams(); + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const queryParamsValidation = sortUrlQueryParamsSchema.safeParse( + qs.parse(searchParams.toString()), + ); + + const sortQueryParams = useMemo( + () => + queryParamsValidation.success ? queryParamsValidation.data.sort : {}, + [queryParamsValidation], + ); + + const hasSortsQueryParams = + isDefined(sortQueryParams) && Object.entries(sortQueryParams).length > 0; + + const getSortsFromQueryParams = useCallback((): RecordSort[] => { + if (!hasSortsQueryParams) return []; + + return Object.entries(sortQueryParams) + .map(([fieldName, direction]) => { + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === fieldName, + ); + + if (!fieldMetadataItem) return null; + + return { + id: `tmp-sort-${fieldName}`, + fieldMetadataId: fieldMetadataItem.id, + direction: direction as ViewSortDirection, + }; + }) + .filter(isDefined); + }, [hasSortsQueryParams, sortQueryParams, objectMetadataItem.fields]); + + return { + hasSortsQueryParams, + getSortsFromQueryParams, + objectMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/views/schemas/sortUrlQueryParamsSchema.ts b/packages/twenty-front/src/modules/views/schemas/sortUrlQueryParamsSchema.ts new file mode 100644 index 0000000000..6f445c460d --- /dev/null +++ b/packages/twenty-front/src/modules/views/schemas/sortUrlQueryParamsSchema.ts @@ -0,0 +1,10 @@ +import { ViewSortDirection } from '~/generated/graphql'; +import z from 'zod'; + +export const sortUrlQueryParamsSchema = z.object({ + sort: z + .record(z.string(), z.enum([ViewSortDirection.ASC, ViewSortDirection.DESC])) + .optional(), +}); + +export type SortQueryParams = z.infer;