From 3be3c4e965a4f0b7ddff210bfb6bdba94e6a3957 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:56:55 +0530 Subject: [PATCH] part 2 of filter/sort drill down from charts (#16013) in this PR, we will handle simple filter translations (ie, fields that use the contains operand) --- .../GraphWidgetBarChartRenderer.tsx | 38 +++- .../hooks/useGraphBarChartWidgetData.ts | 11 +- .../transformGroupByDataToBarChartData.ts | 4 + ...formOneDimensionalGroupByToBarChartData.ts | 15 ++ ...formTwoDimensionalGroupByToBarChartData.ts | 17 ++ .../GraphWidgetLineChartRenderer.tsx | 37 +++- .../hooks/useGraphLineChartWidgetData.ts | 7 +- .../BuildChartDrilldownQueryParamsInput.ts | 16 ++ .../widgets/graph/types/RawDimensionValue.ts | 1 + ...ppendNestedUrlFilterGroupsToQueryParams.ts | 47 ++++ .../utils/buildChartDrilldownQueryParams.ts | 53 +++++ .../graph/utils/buildFilterFromChartBucket.ts | 86 ++++++++ .../graph/utils/buildFilterQueryParams.ts | 79 +++++++ .../graph/utils/buildFormattedToRawLookup.ts | 18 ++ .../utils/formatPrimaryDimensionValues.ts | 54 +++++ .../mapRecordFilterGroupToUrlFilterGroup.ts | 88 ++++++++ .../graph/utils/mapRecordFilterToUrlFilter.ts | 39 ++++ .../transformGroupByDataToLineChartData.ts | 4 + ...ormOneDimensionalGroupByToLineChartData.ts | 15 ++ ...ormTwoDimensionalGroupByToLineChartData.ts | 17 +- .../components/QueryParamsFiltersEffect.tsx | 86 ++++++-- .../components/UpdateViewButtonGroup.tsx | 4 +- .../views/components/ViewBarDetails.tsx | 4 +- .../internal/useFiltersFromQueryParams.ts | 140 ++++++++++++ .../internal/useHasFiltersInQueryParams.ts | 30 +++ .../hooks/internal/useViewFromQueryParams.ts | 201 ------------------ .../schemas/filterUrlQueryParamsSchema.ts | 17 ++ .../schemas/urlRecursiveFilterGroupSchema.ts | 18 ++ .../views/schemas/urlSingleFilterSchema.ts | 9 + .../types/UrlFilterDeserializationResult.ts | 7 + .../views/types/UrlRecursiveFilterGroup.ts | 6 + .../modules/views/types/UrlSingleFilter.ts | 4 + .../convertUrlSingleFilterToRecordFilter.ts | 69 ++++++ .../deserializeUrlRecursiveFilterGroup.ts | 85 ++++++++ .../splitFieldNameIntoBaseAndSubField.ts | 16 ++ 35 files changed, 1097 insertions(+), 245 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/types/RawDimensionValue.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/appendNestedUrlFilterGroupsToQueryParams.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterQueryParams.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterGroupToUrlFilterGroup.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterToUrlFilter.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/internal/useFiltersFromQueryParams.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/internal/useHasFiltersInQueryParams.ts delete mode 100644 packages/twenty-front/src/modules/views/hooks/internal/useViewFromQueryParams.ts create mode 100644 packages/twenty-front/src/modules/views/schemas/filterUrlQueryParamsSchema.ts create mode 100644 packages/twenty-front/src/modules/views/schemas/urlRecursiveFilterGroupSchema.ts create mode 100644 packages/twenty-front/src/modules/views/schemas/urlSingleFilterSchema.ts create mode 100644 packages/twenty-front/src/modules/views/types/UrlFilterDeserializationResult.ts create mode 100644 packages/twenty-front/src/modules/views/types/UrlRecursiveFilterGroup.ts create mode 100644 packages/twenty-front/src/modules/views/types/UrlSingleFilter.ts create mode 100644 packages/twenty-front/src/modules/views/utils/convertUrlSingleFilterToRecordFilter.ts create mode 100644 packages/twenty-front/src/modules/views/utils/deserializeUrlRecursiveFilterGroup.ts create mode 100644 packages/twenty-front/src/modules/views/utils/splitFieldNameIntoBaseAndSubField.ts 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 ef9ebf863f3..3e90cb49bb3 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 @@ -3,7 +3,10 @@ import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/gr import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode'; +import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { type ComputedDatum } from '@nivo/bar'; import { lazy, Suspense } from 'react'; @@ -40,6 +43,7 @@ export const GraphWidgetBarChartRenderer = ({ layout, loading, hasTooManyGroups, + formattedToRawLookup, objectMetadataItem, } = useGraphBarChartWidgetData({ objectMetadataItemId: widget.objectMetadataId, @@ -48,6 +52,9 @@ export const GraphWidgetBarChartRenderer = ({ const navigate = useNavigate(); const configuration = widget.configuration as BarChartConfiguration; + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); const hasGroupByOnSecondaryAxis = isDefined( configuration.secondaryAxisGroupByFieldMetadataId, @@ -68,16 +75,27 @@ export const GraphWidgetBarChartRenderer = ({ }), ); - const handleBarClick = (_datum: ComputedDatum) => { - return navigate( - getAppPath( - AppPath.RecordIndexPage, - { - objectNamePlural: objectMetadataItem.namePlural, - }, - isDefined(indexViewId) ? { viewId: indexViewId } : undefined, - ), + const handleBarClick = (datum: ComputedDatum) => { + const displayValue = datum.data[indexBy]; + const rawValue = formattedToRawLookup.get(displayValue as string) ?? null; + + const queryParams = buildChartDrilldownQueryParams({ + objectMetadataItem, + configuration, + clickedData: { + primaryBucketRawValue: rawValue, + }, + viewId: indexViewId, + timezone: configuration.timezone ?? undefined, + }); + + const url = getAppPath( + AppPath.RecordIndexPage, + { objectNamePlural: objectMetadataItem.namePlural }, + Object.fromEntries(queryParams), ); + + navigate(url); }; if (loading) { @@ -105,7 +123,7 @@ export const GraphWidgetBarChartRenderer = ({ rangeMin={configuration.rangeMin ?? undefined} rangeMax={configuration.rangeMax ?? undefined} omitNullValues={configuration.omitNullValues ?? false} - onBarClick={handleBarClick} + onBarClick={isPageLayoutInEditMode ? undefined : handleBarClick} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts index c0c1e7f8d87..6f50ffbc563 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -1,11 +1,11 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; -import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { type BarDatum } from '@nivo/bar'; import { useMemo } from 'react'; import { type BarChartConfiguration } from '~/generated/graphql'; @@ -15,7 +15,7 @@ type UseGraphBarChartWidgetDataProps = { }; type UseGraphBarChartWidgetDataResult = { - data: BarChartDataItem[]; + data: BarDatum[]; indexBy: string; keys: string[]; series: BarChartSeries[]; @@ -26,7 +26,10 @@ type UseGraphBarChartWidgetDataResult = { loading: boolean; error?: Error; hasTooManyGroups: boolean; - objectMetadataItem: ObjectMetadataItem; + formattedToRawLookup: Map; + objectMetadataItem: ReturnType< + typeof useObjectMetadataItemById + >['objectMetadataItem']; }; // TODO: Remove this once backend returns total group count diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts index c678ba9f050..19e6dd9f478 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts @@ -11,6 +11,7 @@ import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWid import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData'; import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; import { isDefined } from 'twenty-shared/utils'; @@ -38,6 +39,7 @@ type TransformGroupByDataToBarChartDataResult = { showDataLabels: boolean; layout?: BarChartLayout; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = { @@ -50,6 +52,7 @@ const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = { showDataLabels: false, layout: BarChartLayout.VERTICAL, hasTooManyGroups: false, + formattedToRawLookup: new Map(), }; export const transformGroupByDataToBarChartData = ({ @@ -195,5 +198,6 @@ export const transformGroupByDataToBarChartData = ({ showDataLabels, layout, hasTooManyGroups: baseResult.hasTooManyGroups || dateRangeWasTruncated, + formattedToRawLookup: baseResult.formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts index c28aeaace18..6ba3cbb1ca0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts @@ -7,6 +7,9 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; +import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; @@ -29,6 +32,7 @@ type TransformOneDimensionalGroupByToBarChartDataResult = { keys: string[]; series: BarChartSeries[]; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; export const transformOneDimensionalGroupByToBarChartData = ({ @@ -53,6 +57,16 @@ export const transformOneDimensionalGroupByToBarChartData = ({ // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) const limitedResults = rawResults.slice(0, BAR_CHART_MAXIMUM_NUMBER_OF_BARS); + const formattedValues = formatPrimaryDimensionValues({ + groupByRawResults: limitedResults, + primaryAxisGroupByField: groupByFieldX, + primaryAxisDateGranularity: + configuration.primaryAxisDateGranularity ?? undefined, + primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + }); + + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); + const data: BarChartDataItem[] = limitedResults.map((result) => { const dimensionValues = result.groupByDimensionValues; @@ -96,5 +110,6 @@ export const transformOneDimensionalGroupByToBarChartData = ({ keys: [aggregateValueKey], series, hasTooManyGroups: rawResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS, + formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts index 4c95d03ff5d..b4abbdda042 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts @@ -7,6 +7,9 @@ import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarC import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum'; 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 { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; @@ -34,6 +37,7 @@ type TransformTwoDimensionalGroupByToBarChartDataResult = { keys: string[]; series: BarChartSeries[]; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; export const transformTwoDimensionalGroupByToBarChartData = ({ @@ -54,6 +58,14 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ const dataMap = new Map(); const xValues = new Set(); const yValues = new Set(); + const formattedValues = formatPrimaryDimensionValues({ + groupByRawResults: rawResults, + primaryAxisGroupByField: groupByFieldX, + primaryAxisDateGranularity: + configuration.primaryAxisDateGranularity ?? undefined, + primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + }); + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); let hasTooManyGroups = false; @@ -75,6 +87,10 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined, }); + if (isDefined(dimensionValues[0])) { + formattedToRawLookup.set(xValue, dimensionValues[0] as RawDimensionValue); + } + // TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600) const isNewX = !xValues.has(xValue); const isNewY = !yValues.has(yValue); @@ -151,5 +167,6 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ keys, series, hasTooManyGroups, + formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx index fddc21fd234..0edbe5d6ad8 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx @@ -2,7 +2,11 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect'; import { LINE_CHART_IS_STACKED_DEFAULT } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartIsStackedDefault'; import { useGraphLineChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData'; +import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint'; +import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { type LineSeries, type Point } from '@nivo/line'; import { lazy, Suspense } from 'react'; @@ -35,6 +39,7 @@ export const GraphWidgetLineChartRenderer = ({ showDataLabels, hasTooManyGroups, loading, + formattedToRawLookup, objectMetadataItem, } = useGraphLineChartWidgetData({ objectMetadataItemId: widget.objectMetadataId, @@ -43,6 +48,9 @@ export const GraphWidgetLineChartRenderer = ({ const navigate = useNavigate(); const configuration = widget.configuration as LineChartConfiguration; + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); const hasGroupByOnSecondaryAxis = isDefined( configuration.secondaryAxisGroupByFieldMetadataId, @@ -66,14 +74,27 @@ export const GraphWidgetLineChartRenderer = ({ }), ); - const handlePointClick = (_point: Point) => { - return navigate( - getAppPath( - AppPath.RecordIndexPage, - { objectNamePlural: objectMetadataItem.namePlural }, - isDefined(indexViewId) ? { viewId: indexViewId } : undefined, - ), + const handlePointClick = (point: Point) => { + const xValue = (point.data as LineChartDataPoint).x; + const rawValue = formattedToRawLookup.get(xValue as string) ?? null; + + const queryParams = buildChartDrilldownQueryParams({ + objectMetadataItem, + configuration, + clickedData: { + primaryBucketRawValue: rawValue, + }, + viewId: indexViewId, + timezone: configuration.timezone ?? undefined, + }); + + const url = getAppPath( + AppPath.RecordIndexPage, + { objectNamePlural: objectMetadataItem.namePlural }, + Object.fromEntries(queryParams), ); + + navigate(url); }; if (loading) { @@ -97,7 +118,7 @@ export const GraphWidgetLineChartRenderer = ({ omitNullValues={configuration.omitNullValues ?? false} groupMode={groupMode} displayType="shortNumber" - onSliceClick={handlePointClick} + onSliceClick={isPageLayoutInEditMode ? undefined : handlePointClick} /> ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts index 0e68d524bf0..e8aa0d6116d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts @@ -1,7 +1,7 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; -import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData'; import { useMemo } from 'react'; import { type LineChartConfiguration } from '~/generated/graphql'; @@ -17,9 +17,12 @@ type UseGraphLineChartWidgetDataResult = { yAxisLabel?: string; showDataLabels: boolean; hasTooManyGroups: boolean; + formattedToRawLookup: Map; loading: boolean; error?: Error; - objectMetadataItem: ObjectMetadataItem; + objectMetadataItem: ReturnType< + typeof useObjectMetadataItemById + >['objectMetadataItem']; }; export const useGraphLineChartWidgetData = ({ 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 new file mode 100644 index 00000000000..15a7cf8eae1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts @@ -0,0 +1,16 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { + type BarChartConfiguration, + type LineChartConfiguration, +} from '~/generated/graphql'; + +export type BuildChartDrilldownQueryParamsInput = { + objectMetadataItem: ObjectMetadataItem; + configuration: BarChartConfiguration | LineChartConfiguration; + clickedData: { + primaryBucketRawValue: RawDimensionValue; + }; + viewId?: string; + timezone?: string; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/RawDimensionValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/RawDimensionValue.ts new file mode 100644 index 00000000000..605e190a72a --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/RawDimensionValue.ts @@ -0,0 +1 @@ +export type RawDimensionValue = string | number | Date | null; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/appendNestedUrlFilterGroupsToQueryParams.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/appendNestedUrlFilterGroupsToQueryParams.ts new file mode 100644 index 00000000000..c474cd98b1f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/appendNestedUrlFilterGroupsToQueryParams.ts @@ -0,0 +1,47 @@ +import { isDefined } from 'twenty-shared/utils'; +import { type UrlFilterGroup } from './mapRecordFilterGroupToUrlFilterGroup'; + +export const appendNestedUrlFilterGroupsToQueryParams = ( + urlFilterGroups: UrlFilterGroup[], + queryParamsPrefix: string, + targetQueryParams: URLSearchParams, +): void => { + for (const [groupIndex, urlFilterGroup] of urlFilterGroups.entries()) { + const currentGroupPrefix = `${queryParamsPrefix}[${groupIndex}]`; + targetQueryParams.set( + `${currentGroupPrefix}[operator]`, + urlFilterGroup.operator, + ); + + if (isDefined(urlFilterGroup.filters)) { + for (const [filterIndex, urlFilter] of urlFilterGroup.filters.entries()) { + targetQueryParams.set( + `${currentGroupPrefix}[filters][${filterIndex}][field]`, + urlFilter.field, + ); + targetQueryParams.set( + `${currentGroupPrefix}[filters][${filterIndex}][op]`, + urlFilter.op, + ); + targetQueryParams.set( + `${currentGroupPrefix}[filters][${filterIndex}][value]`, + urlFilter.value, + ); + if (isDefined(urlFilter.subField)) { + targetQueryParams.set( + `${currentGroupPrefix}[filters][${filterIndex}][subField]`, + urlFilter.subField, + ); + } + } + } + + if (isDefined(urlFilterGroup.groups)) { + appendNestedUrlFilterGroupsToQueryParams( + urlFilterGroup.groups, + `${currentGroupPrefix}[groups]`, + targetQueryParams, + ); + } + } +}; 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 new file mode 100644 index 00000000000..4c7f73a0fe6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts @@ -0,0 +1,53 @@ +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 { isDefined } from 'twenty-shared/utils'; + +export const buildChartDrilldownQueryParams = ({ + objectMetadataItem, + configuration, + clickedData, + viewId, + timezone, +}: BuildChartDrilldownQueryParamsInput): URLSearchParams => { + const drilldownQueryParams = new URLSearchParams(); + + if (isDefined(configuration.filter)) { + const chartFilterParams = buildFilterQueryParams({ + recordFilters: configuration.filter.recordFilters ?? [], + recordFilterGroups: configuration.filter.recordFilterGroups ?? [], + objectMetadataItem, + }); + + chartFilterParams.forEach((value, key) => { + drilldownQueryParams.append(key, value); + }); + } + + const primaryField = objectMetadataItem.fields.find( + (field) => field.id === configuration.primaryAxisGroupByFieldMetadataId, + ); + + if (isDefined(primaryField)) { + const primaryFilters = buildFilterFromChartBucket({ + fieldMetadataItem: primaryField, + bucketRawValue: clickedData.primaryBucketRawValue, + dateGranularity: configuration.primaryAxisDateGranularity, + subFieldName: configuration.primaryAxisGroupBySubFieldName, + timezone, + }); + + primaryFilters.forEach((filter) => { + drilldownQueryParams.append( + `filter[${filter.fieldName}][${filter.operand}]`, + filter.value, + ); + }); + } + + if (isDefined(viewId)) { + drilldownQueryParams.set('viewId', viewId); + } + + return drilldownQueryParams; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts new file mode 100644 index 00000000000..ab5a33529e9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts @@ -0,0 +1,86 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { isNonEmptyString } from '@sniptt/guards'; +import { + ViewFilterOperand, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; +import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +type ChartFilter = { + fieldName: string; + operand: ViewFilterOperand; + value: string; +}; + +type BuildFilterFromChartBucketParams = { + fieldMetadataItem: FieldMetadataItem; + bucketRawValue: unknown; + dateGranularity?: ObjectRecordGroupByDateGranularity | null; // TODO: Will be used for date filtering + subFieldName?: string | null; + timezone?: string; // TODO: Will be used for date filtering +}; + +const formatChartFilterValue = ( + fieldType: FieldMetadataType, + bucketRawValue: unknown, + operand: ViewFilterOperand, +): string => { + const stringValue = String(bucketRawValue); + + const needsJsonArray = + operand === ViewFilterOperand.IS && + [ + FieldMetadataType.SELECT, + FieldMetadataType.UUID, + FieldMetadataType.RELATION, + ].includes(fieldType); + + return needsJsonArray ? JSON.stringify([stringValue]) : stringValue; +}; + +export const buildFilterFromChartBucket = ({ + fieldMetadataItem, + bucketRawValue, + subFieldName, +}: BuildFilterFromChartBucketParams): ChartFilter[] => { + const fieldName = isNonEmptyString(subFieldName) + ? `${fieldMetadataItem.name}.${subFieldName}` + : fieldMetadataItem.name; + + if (!isDefined(bucketRawValue) || !isNonEmptyString(String(bucketRawValue))) { + return [ + { + fieldName, + operand: ViewFilterOperand.IS_EMPTY, + value: '', + }, + ]; + } + + const availableOperands = getRecordFilterOperands({ + filterType: getFilterTypeFromFieldType(fieldMetadataItem.type), + subFieldName: subFieldName ?? undefined, + }); + + if (availableOperands.length === 0) { + return []; + } + + const operand = availableOperands[0]; + + const value = formatChartFilterValue( + fieldMetadataItem.type, + bucketRawValue, + operand, + ); + + return [ + { + fieldName, + operand, + value, + }, + ]; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterQueryParams.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterQueryParams.ts new file mode 100644 index 00000000000..ac10df584f9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterQueryParams.ts @@ -0,0 +1,79 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { isDefined } from 'twenty-shared/utils'; +import { appendNestedUrlFilterGroupsToQueryParams } from './appendNestedUrlFilterGroupsToQueryParams'; +import { mapRecordFilterToUrlFilter } from './mapRecordFilterToUrlFilter'; +import { mapRecordFilterGroupToUrlFilterGroup } from './mapRecordFilterGroupToUrlFilterGroup'; + +export const buildFilterQueryParams = ({ + recordFilters = [], + recordFilterGroups = [], + objectMetadataItem, +}: { + recordFilters?: RecordFilter[]; + recordFilterGroups?: RecordFilterGroup[]; + objectMetadataItem: ObjectMetadataItem; +}): URLSearchParams => { + const params = new URLSearchParams(); + + const rootGroup = recordFilterGroups.find( + (group) => !isDefined(group.parentRecordFilterGroupId), + ); + + if (isDefined(rootGroup)) { + const urlFilterGroup = mapRecordFilterGroupToUrlFilterGroup({ + recordFilterGroupId: rootGroup.id, + allRecordFilters: recordFilters, + allRecordFilterGroups: recordFilterGroups, + objectMetadataItem, + }); + + if (isDefined(urlFilterGroup)) { + params.set('filterGroup[operator]', urlFilterGroup.operator); + + if (isDefined(urlFilterGroup.filters)) { + for (const [index, filter] of urlFilterGroup.filters.entries()) { + params.set(`filterGroup[filters][${index}][field]`, filter.field); + params.set(`filterGroup[filters][${index}][op]`, filter.op); + params.set(`filterGroup[filters][${index}][value]`, filter.value); + if (isDefined(filter.subField)) { + params.set( + `filterGroup[filters][${index}][subField]`, + filter.subField, + ); + } + } + } + + if (isDefined(urlFilterGroup.groups)) { + appendNestedUrlFilterGroupsToQueryParams( + urlFilterGroup.groups, + 'filterGroup[groups]', + params, + ); + } + } + } else { + const parentlessFilters = recordFilters.filter( + (filter) => !isDefined(filter.recordFilterGroupId), + ); + + for (const filter of parentlessFilters) { + const urlFilter = mapRecordFilterToUrlFilter({ + recordFilter: filter, + objectMetadataItem, + }); + + if (isDefined(urlFilter)) { + const fieldName = isDefined(urlFilter.subField) + ? `${urlFilter.field}.${urlFilter.subField}` + : urlFilter.field; + + params.append(`filter[${fieldName}][${urlFilter.op}]`, urlFilter.value); + } + } + } + + return params; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts new file mode 100644 index 00000000000..e0a6ce89dd3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFormattedToRawLookup.ts @@ -0,0 +1,18 @@ +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { type FormattedDimensionValue } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; + +export const buildFormattedToRawLookup = ( + formattedValues: FormattedDimensionValue[], +): Map => { + const lookup = new Map(); + + formattedValues.forEach( + ({ formattedPrimaryDimensionValue, rawPrimaryDimensionValue }) => { + if (!lookup.has(formattedPrimaryDimensionValue)) { + lookup.set(formattedPrimaryDimensionValue, rawPrimaryDimensionValue); + } + }, + ); + + return lookup; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts new file mode 100644 index 00000000000..68c1e48769d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts @@ -0,0 +1,54 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { isDefined } from 'twenty-shared/utils'; +import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql'; + +type FormatPrimaryDimensionValuesParameters = { + groupByRawResults: GroupByRawResult[]; + primaryAxisGroupByField: FieldMetadataItem; + primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity; + primaryAxisGroupBySubFieldName?: string; +}; + +export type FormattedDimensionValue = { + formattedPrimaryDimensionValue: string; + rawPrimaryDimensionValue: RawDimensionValue; +}; + +export const formatPrimaryDimensionValues = ({ + groupByRawResults, + primaryAxisGroupByField, + primaryAxisDateGranularity, + primaryAxisGroupBySubFieldName, +}: FormatPrimaryDimensionValuesParameters): FormattedDimensionValue[] => { + return groupByRawResults.reduce( + (accumulator, rawResult) => { + const groupByDimensionValues = rawResult.groupByDimensionValues; + + if (!isDefined(groupByDimensionValues?.[0])) { + return accumulator; + } + + const rawPrimaryDimensionValue = + groupByDimensionValues[0] as RawDimensionValue; + + const formattedPrimaryDimensionValue = formatDimensionValue({ + value: rawPrimaryDimensionValue, + fieldMetadata: primaryAxisGroupByField, + dateGranularity: primaryAxisDateGranularity, + subFieldName: primaryAxisGroupBySubFieldName, + }); + + return [ + ...accumulator, + { + formattedPrimaryDimensionValue, + rawPrimaryDimensionValue, + }, + ]; + }, + [], + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterGroupToUrlFilterGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterGroupToUrlFilterGroup.ts new file mode 100644 index 00000000000..cdcd788374f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterGroupToUrlFilterGroup.ts @@ -0,0 +1,88 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { isDefined } from 'twenty-shared/utils'; +import { + mapRecordFilterToUrlFilter, + type UrlFilter, +} from './mapRecordFilterToUrlFilter'; + +export type UrlFilterGroup = { + operator: string; + filters?: UrlFilter[]; + groups?: UrlFilterGroup[]; +}; + +export const mapRecordFilterGroupToUrlFilterGroup = ({ + recordFilterGroupId, + allRecordFilters, + allRecordFilterGroups, + objectMetadataItem, +}: { + recordFilterGroupId: string; + allRecordFilters: RecordFilter[]; + allRecordFilterGroups: RecordFilterGroup[]; + objectMetadataItem: ObjectMetadataItem; +}): UrlFilterGroup | null => { + const currentGroup = allRecordFilterGroups.find( + (group) => group.id === recordFilterGroupId, + ); + + if (!isDefined(currentGroup)) { + return null; + } + + const urlFilterGroup: UrlFilterGroup = { + operator: currentGroup.logicalOperator, + }; + + const filtersInGroup = allRecordFilters + .filter((filter) => filter.recordFilterGroupId === recordFilterGroupId) + .sort( + (a, b) => + (a.positionInRecordFilterGroup ?? 0) - + (b.positionInRecordFilterGroup ?? 0), + ); + + if (filtersInGroup.length > 0) { + const urlFilters = filtersInGroup + .map((filter) => + mapRecordFilterToUrlFilter({ + recordFilter: filter, + objectMetadataItem, + }), + ) + .filter(isDefined); + + if (urlFilters.length > 0) { + urlFilterGroup.filters = urlFilters; + } + } + + const childGroups = allRecordFilterGroups + .filter((group) => group.parentRecordFilterGroupId === recordFilterGroupId) + .sort( + (a, b) => + (a.positionInRecordFilterGroup ?? 0) - + (b.positionInRecordFilterGroup ?? 0), + ); + + if (childGroups.length > 0) { + const urlChildGroups = childGroups + .map((childGroup) => + mapRecordFilterGroupToUrlFilterGroup({ + recordFilterGroupId: childGroup.id, + allRecordFilters, + allRecordFilterGroups, + objectMetadataItem, + }), + ) + .filter(isDefined); + + if (urlChildGroups.length > 0) { + urlFilterGroup.groups = urlChildGroups; + } + } + + return urlFilterGroup; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterToUrlFilter.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterToUrlFilter.ts new file mode 100644 index 00000000000..20ae580a0da --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapRecordFilterToUrlFilter.ts @@ -0,0 +1,39 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +export type UrlFilter = { + field: string; + op: string; + value: string; + subField?: string; +}; + +export const mapRecordFilterToUrlFilter = ({ + recordFilter, + objectMetadataItem, +}: { + recordFilter: RecordFilter; + objectMetadataItem: ObjectMetadataItem; +}): UrlFilter | null => { + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === recordFilter.fieldMetadataId, + ); + + if (!isDefined(fieldMetadataItem)) { + return null; + } + + const urlFilter: UrlFilter = { + field: fieldMetadataItem.name, + op: recordFilter.operand, + value: recordFilter.value, + }; + + if (isNonEmptyString(recordFilter.subFieldName)) { + urlFilter.subField = recordFilter.subFieldName; + } + + return urlFilter; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts index 62f82409537..5983fc26986 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts @@ -5,6 +5,7 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData'; import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData'; @@ -27,6 +28,7 @@ type TransformGroupByDataToLineChartDataResult = { yAxisLabel?: string; showDataLabels: boolean; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = { @@ -35,6 +37,7 @@ const EMPTY_LINE_CHART_RESULT: TransformGroupByDataToLineChartDataResult = { yAxisLabel: undefined, showDataLabels: false, hasTooManyGroups: false, + formattedToRawLookup: new Map(), }; export const transformGroupByDataToLineChartData = ({ @@ -137,5 +140,6 @@ export const transformGroupByDataToLineChartData = ({ xAxisLabel, yAxisLabel, showDataLabels, + formattedToRawLookup: baseResult.formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts index 80baa3cbc39..b350fecbc85 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts @@ -7,6 +7,9 @@ import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidget import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; +import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { isDefined } from 'twenty-shared/utils'; @@ -25,6 +28,7 @@ type TransformOneDimensionalGroupByToLineChartDataParams = { type TransformOneDimensionalGroupByToLineChartDataResult = { series: LineChartSeries[]; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; export const transformOneDimensionalGroupByToLineChartData = ({ @@ -42,6 +46,16 @@ export const transformOneDimensionalGroupByToLineChartData = ({ LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS, ); + const formattedValues = formatPrimaryDimensionValues({ + groupByRawResults: limitedResults, + primaryAxisGroupByField: groupByFieldX, + primaryAxisDateGranularity: + configuration.primaryAxisDateGranularity ?? undefined, + primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + }); + + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); + const data: LineChartDataPoint[] = limitedResults .map((result) => { const dimensionValues = result.groupByDimensionValues; @@ -90,5 +104,6 @@ export const transformOneDimensionalGroupByToLineChartData = ({ series, hasTooManyGroups: rawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS, + formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts index bdb292274ca..65d2ae24b1d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts @@ -6,6 +6,9 @@ import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidget import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup'; +import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries'; @@ -26,6 +29,7 @@ type TransformTwoDimensionalGroupByToLineChartDataParams = { type TransformTwoDimensionalGroupByToLineChartDataResult = { series: LineChartSeries[]; hasTooManyGroups: boolean; + formattedToRawLookup: Map; }; export const transformTwoDimensionalGroupByToLineChartData = ({ @@ -41,6 +45,14 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ const seriesMap = new Map>(); const allXValues: string[] = []; const xValueSet = new Set(); + const formattedValues = formatPrimaryDimensionValues({ + groupByRawResults: rawResults, + primaryAxisGroupByField: groupByFieldX, + primaryAxisDateGranularity: + configuration.primaryAxisDateGranularity ?? undefined, + primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + }); + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); let hasTooManyGroups = false; rawResults.forEach((result) => { @@ -70,8 +82,10 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ allXValues.push(xValue); } + const seriesRawValue = dimensionValues[1]; + const seriesKey = formatDimensionValue({ - value: dimensionValues[1], + value: seriesRawValue, fieldMetadata: groupByFieldY, dateGranularity: configuration.secondaryAxisGroupByDateGranularity ?? undefined, @@ -120,5 +134,6 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ return { series, hasTooManyGroups, + formattedToRawLookup, }; }; diff --git a/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx index de06205f2b2..92c9a8eff39 100644 --- a/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/QueryParamsFiltersEffect.tsx @@ -1,20 +1,44 @@ import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; -import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; -import { useApplyViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyViewFiltersToCurrentRecordFilters'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useFiltersFromQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams'; +import { useHasFiltersInQueryParams } from '@/views/hooks/internal/useHasFiltersInQueryParams'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useMapViewFiltersToFilters } from '@/views/hooks/useMapViewFiltersToFilters'; +import { isDefined } from 'twenty-shared/utils'; export const QueryParamsFiltersEffect = () => { - const { - hasFiltersQueryParams, - getFiltersFromQueryParams, - objectMetadataItem, - } = useViewFromQueryParams(); + const { getFiltersFromQueryParams, getFilterGroupsFromQueryParams } = + useFiltersFromQueryParams(); + const { hasFiltersQueryParams } = useHasFiltersInQueryParams(); + + const { objectNamePlural = '' } = useParams(); + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); const { currentView } = useGetCurrentViewOnly(); - const { applyViewFiltersToCurrentRecordFilters } = - useApplyViewFiltersToCurrentRecordFilters(); + const { mapViewFiltersToRecordFilters } = useMapViewFiltersToFilters(); + + const { recordIndexId } = useRecordIndexContextOrThrow(); + const setCurrentRecordFilters = useSetRecoilComponentState( + currentRecordFiltersComponentState, + recordIndexId, + ); + const setCurrentRecordFilterGroups = useSetRecoilComponentState( + currentRecordFilterGroupsComponentState, + recordIndexId, + ); const currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem = currentView?.objectMetadataId !== objectMetadataItem.id; @@ -27,17 +51,49 @@ export const QueryParamsFiltersEffect = () => { return; } - getFiltersFromQueryParams().then((filtersFromParams) => { - if (Array.isArray(filtersFromParams)) { - applyViewFiltersToCurrentRecordFilters(filtersFromParams); + const loadFiltersFromQueryParams = async () => { + const [filtersFromParams, filterGroupsFromParams] = await Promise.all([ + getFiltersFromQueryParams(), + getFilterGroupsFromQueryParams(), + ]); + + const allRecordFilters = []; + + if ( + isDefined(filterGroupsFromParams) && + filterGroupsFromParams.recordFilters.length > 0 + ) { + allRecordFilters.push(...filterGroupsFromParams.recordFilters); } - }); + + if (Array.isArray(filtersFromParams) && filtersFromParams.length > 0) { + const simpleRecordFilters = + mapViewFiltersToRecordFilters(filtersFromParams); + allRecordFilters.push(...simpleRecordFilters); + } + + if ( + isDefined(filterGroupsFromParams) && + filterGroupsFromParams.recordFilterGroups.length > 0 + ) { + setCurrentRecordFilterGroups(filterGroupsFromParams.recordFilterGroups); + } + + if (allRecordFilters.length > 0) { + setCurrentRecordFilters(allRecordFilters); + } + }; + + loadFiltersFromQueryParams(); }, [ currentViewObjectMetadataItemIsDifferentFromURLObjectMetadataItem, - applyViewFiltersToCurrentRecordFilters, + mapViewFiltersToRecordFilters, getFiltersFromQueryParams, + getFilterGroupsFromQueryParams, hasFiltersQueryParams, + setCurrentRecordFilterGroups, + setCurrentRecordFilters, ]); - return <>; + return null; }; diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index 57d0f6bf70a..01b7136ce24 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -10,7 +10,7 @@ import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { UPDATE_VIEW_BUTTON_DROPDOWN_ID } from '@/views/constants/UpdateViewButtonDropdownId'; -import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; +import { useHasFiltersInQueryParams } from '@/views/hooks/internal/useHasFiltersInQueryParams'; import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups'; import { useAreViewFiltersDifferentFromRecordFilters } from '@/views/hooks/useAreViewFiltersDifferentFromRecordFilters'; import { useAreViewSortsDifferentFromRecordSorts } from '@/views/hooks/useAreViewSortsDifferentFromRecordSorts'; @@ -85,7 +85,7 @@ export const UpdateViewButtonGroup = () => { await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id); }; - const { hasFiltersQueryParams } = useViewFromQueryParams(); + const { hasFiltersQueryParams } = useHasFiltersInQueryParams(); const { viewFilterGroupsAreDifferentFromRecordFilterGroups } = useAreViewFilterGroupsDifferentFromRecordFilterGroups(); diff --git a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx index cec9a9d540d..cd613af345e 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarDetails.tsx @@ -9,7 +9,6 @@ import { AdvancedFilterDropdownButton } from '@/views/components/AdvancedFilterD import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton'; import { EditableSortChip } from '@/views/components/EditableSortChip'; import { ViewBarDetailsAddFilterButton } from '@/views/components/ViewBarDetailsAddFilterButton'; -import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; @@ -26,6 +25,7 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { AnyFieldSearchDropdownButton } from '@/views/components/AnyFieldSearchDropdownButton'; import { ANY_FIELD_SEARCH_DROPDOWN_ID } from '@/views/constants/AnyFieldSearchDropdownId'; +import { useHasFiltersInQueryParams } from '@/views/hooks/internal/useHasFiltersInQueryParams'; import { useApplyCurrentViewAnyFieldFilterToAnyFieldFilter } from '@/views/hooks/useApplyCurrentViewAnyFieldFilterToAnyFieldFilter'; import { useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups } from '@/views/hooks/useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups'; import { useAreViewFilterGroupsDifferentFromRecordFilterGroups } from '@/views/hooks/useAreViewFilterGroupsDifferentFromRecordFilterGroups'; @@ -109,7 +109,7 @@ export const ViewBarDetails = ({ isViewBarExpandedComponentState, ); - const { hasFiltersQueryParams } = useViewFromQueryParams(); + const { hasFiltersQueryParams } = useHasFiltersInQueryParams(); const currentRecordFilterGroups = useRecoilComponentValue( currentRecordFilterGroupsComponentState, diff --git a/packages/twenty-front/src/modules/views/hooks/internal/useFiltersFromQueryParams.ts b/packages/twenty-front/src/modules/views/hooks/internal/useFiltersFromQueryParams.ts new file mode 100644 index 00000000000..2a07ecfb677 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/internal/useFiltersFromQueryParams.ts @@ -0,0 +1,140 @@ +import { isNonEmptyString, isObject } from '@sniptt/guards'; +import qs from 'qs'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; +import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { filterUrlQueryParamsSchema } from '@/views/schemas/filterUrlQueryParamsSchema'; +import { type ViewFilter } from '@/views/types/ViewFilter'; +import { deserializeUrlRecursiveFilterGroup } from '@/views/utils/deserializeUrlRecursiveFilterGroup'; +import { splitFieldNameIntoBaseAndSubField } from '@/views/utils/splitFieldNameIntoBaseAndSubField'; +import { useCallback } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { type ViewFilterOperand } from 'twenty-shared/types'; +import { isDefined, isExpectedSubFieldName } from 'twenty-shared/utils'; + +export const useFiltersFromQueryParams = () => { + const [searchParams] = useSearchParams(); + const { objectNamePlural = '' } = useParams(); + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const queryParamsValidation = filterUrlQueryParamsSchema.safeParse( + qs.parse(searchParams.toString()), + ); + + const getFiltersFromQueryParams = useCallback(async (): Promise< + ViewFilter[] + > => { + if (!queryParamsValidation.success) return []; + + const filterQueryParams = queryParamsValidation.data.filter; + + if ( + !isDefined(filterQueryParams) || + Object.entries(filterQueryParams).length === 0 + ) { + return []; + } + + const promises: Promise[] = []; + + for (const [fieldName, filterFromURL] of Object.entries( + filterQueryParams, + )) { + for (const [filterOperandFromURL, filterValueFromURL] of Object.entries( + filterFromURL, + )) { + const promise = (async (): Promise => { + const { baseFieldName, subFieldName } = + splitFieldNameIntoBaseAndSubField(fieldName); + + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === baseFieldName, + ); + + if (!fieldMetadataItem) return null; + + if (isDefined(subFieldName) && isNonEmptyString(subFieldName)) { + if (!isCompositeFieldType(fieldMetadataItem.type)) { + return null; + } + + if ( + !isExpectedSubFieldName( + fieldMetadataItem.type as Parameters< + typeof isExpectedSubFieldName + >[0], + subFieldName as Parameters[1], + subFieldName, + ) + ) { + return null; + } + } + + const filterValueAsString = + Array.isArray(filterValueFromURL) || isObject(filterValueFromURL) + ? JSON.stringify(filterValueFromURL) + : (filterValueFromURL as string); + + const displayValue = filterValueAsString; + + const filterId = `tmp-${[ + fieldName, + filterOperandFromURL, + filterValueFromURL, + ].join('-')}`; + + return { + __typename: 'ViewFilter', + id: filterId, + fieldMetadataId: fieldMetadataItem.id, + operand: filterOperandFromURL as ViewFilterOperand, + value: filterValueAsString, + displayValue, + subFieldName: subFieldName + ? (subFieldName as ViewFilter['subFieldName']) + : undefined, + }; + })(); + + promises.push(promise); + } + } + + return (await Promise.all(promises)).filter(isDefined); + }, [queryParamsValidation, objectMetadataItem.fields]); + + const filterGroupQueryParams = queryParamsValidation.success + ? queryParamsValidation.data.filterGroup + : undefined; + + const getFilterGroupsFromQueryParams = useCallback(async (): Promise<{ + recordFilters: RecordFilter[]; + recordFilterGroups: RecordFilterGroup[]; + }> => { + if (!isDefined(filterGroupQueryParams)) { + return { recordFilters: [], recordFilterGroups: [] }; + } + + return deserializeUrlRecursiveFilterGroup({ + urlRecursiveFilterGroup: filterGroupQueryParams as Parameters< + typeof deserializeUrlRecursiveFilterGroup + >[0]['urlRecursiveFilterGroup'], + objectMetadataItem, + positionInParent: 0, + }); + }, [filterGroupQueryParams, objectMetadataItem]); + + return { + getFiltersFromQueryParams, + getFilterGroupsFromQueryParams, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/useHasFiltersInQueryParams.ts b/packages/twenty-front/src/modules/views/hooks/internal/useHasFiltersInQueryParams.ts new file mode 100644 index 00000000000..94fb337acb1 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/internal/useHasFiltersInQueryParams.ts @@ -0,0 +1,30 @@ +import qs from 'qs'; +import { useSearchParams } from 'react-router-dom'; + +import { filterUrlQueryParamsSchema } from '@/views/schemas/filterUrlQueryParamsSchema'; +import { isDefined } from 'twenty-shared/utils'; + +export const useHasFiltersInQueryParams = () => { + const [searchParams] = useSearchParams(); + + const queryParamsValidation = filterUrlQueryParamsSchema.safeParse( + qs.parse(searchParams.toString()), + ); + + const filterQueryParams = queryParamsValidation.success + ? queryParamsValidation.data.filter + : {}; + + const filterGroupQueryParams = queryParamsValidation.success + ? queryParamsValidation.data.filterGroup + : undefined; + + const hasFiltersQueryParams = + (isDefined(filterQueryParams) && + Object.entries(filterQueryParams).length > 0) || + isDefined(filterGroupQueryParams); + + return { + hasFiltersQueryParams, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/useViewFromQueryParams.ts b/packages/twenty-front/src/modules/views/hooks/internal/useViewFromQueryParams.ts deleted file mode 100644 index 670fa452a9a..00000000000 --- a/packages/twenty-front/src/modules/views/hooks/internal/useViewFromQueryParams.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { isNonEmptyString, isObject } from '@sniptt/guards'; -import qs from 'qs'; -import { useMemo } from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; -import { useRecoilCallback, useRecoilValue } from 'recoil'; -import z from 'zod'; - -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; -import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; -import { type ViewFilter } from '@/views/types/ViewFilter'; -import { ViewFilterOperand } from 'twenty-shared/types'; -import { - isDefined, - relationFilterValueSchemaObject, -} from 'twenty-shared/utils'; - -const filterQueryParamsSchema = z.object({ - viewId: z.string().optional(), - filter: z - .record( - z.string(), - z.partialRecord( - z.enum(ViewFilterOperand), - z.string().or(z.array(z.string())).or(relationFilterValueSchemaObject), - ), - ) - .optional(), -}); - -export type FilterQueryParams = z.infer; - -export const useViewFromQueryParams = () => { - const apolloCoreClient = useApolloCoreClient(); - const [searchParams] = useSearchParams(); - const { objectNamePlural = '' } = useParams(); - const { objectNameSingular } = useObjectNameSingularFromPlural({ - objectNamePlural, - }); - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - - const queryParamsValidation = filterQueryParamsSchema.safeParse( - qs.parse(searchParams.toString()), - ); - - const filterQueryParams = useMemo( - () => - queryParamsValidation.success ? queryParamsValidation.data.filter : {}, - [queryParamsValidation], - ); - const viewIdQueryParam = useMemo( - () => - queryParamsValidation.success - ? queryParamsValidation.data.viewId - : undefined, - [queryParamsValidation], - ); - - const hasFiltersQueryParams = - isDefined(filterQueryParams) && - Object.entries(filterQueryParams).length > 0; - - const getFiltersFromQueryParams = useRecoilCallback( - ({ snapshot }) => - async () => { - if (!hasFiltersQueryParams) return []; - - return ( - await Promise.all( - Object.entries(filterQueryParams).map>( - async ([fieldName, filterFromURL]) => { - const [filterOperandFromURL, filterValueFromURL] = - Object.entries(filterFromURL)[0]; - const fieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.name === fieldName, - ); - - if (!fieldMetadataItem) return null; - - const relationObjectMetadataNameSingular = - fieldMetadataItem.relation?.targetObjectMetadata - ?.nameSingular; - - const relationObjectMetadataNamePlural = - fieldMetadataItem.relation?.targetObjectMetadata?.namePlural; - - const relationObjectMetadataItem = - relationObjectMetadataNameSingular - ? snapshot - .getLoadable( - objectMetadataItemFamilySelector({ - objectName: relationObjectMetadataNameSingular, - objectNameType: 'singular', - }), - ) - .getValue() - : null; - - const satisfiesRelationFilterSchema = - relationFilterValueSchemaObject.safeParse( - filterValueFromURL, - )?.success; - - const relationRecordNames = []; - - if ( - isNonEmptyString(relationObjectMetadataNamePlural) && - isDefined(relationObjectMetadataItem) && - (Array.isArray(filterValueFromURL) || - satisfiesRelationFilterSchema) - ) { - const queryResult = await apolloCoreClient.query< - Record - >({ - query: generateFindManyRecordsQuery({ - objectMetadataItem: relationObjectMetadataItem, - objectMetadataItems, - objectPermissionsByObjectMetadataId, - }), - variables: { - filter: { - id: { - in: satisfiesRelationFilterSchema - ? ( - filterValueFromURL as { - selectedRecordIds: string[]; - } - )?.selectedRecordIds - : filterValueFromURL, - }, - }, - }, - }); - - const relationRecordNamesFromQuery = queryResult.data?.[ - relationObjectMetadataNamePlural - ]?.edges.map( - ({ node: record }) => - getObjectRecordIdentifier({ - objectMetadataItem: relationObjectMetadataItem, - record, - }).name, - ); - - relationRecordNames.push(...relationRecordNamesFromQuery); - } - - const filterValueAsString = - Array.isArray(filterValueFromURL) || - isObject(filterValueFromURL) - ? JSON.stringify(filterValueFromURL) - : filterValueFromURL; - - return { - __typename: 'ViewFilter', - id: `tmp-${[ - fieldName, - filterOperandFromURL, - filterValueFromURL, - ].join('-')}`, - fieldMetadataId: fieldMetadataItem.id, - operand: filterOperandFromURL as ViewFilterOperand, - value: filterValueAsString, - displayValue: - relationRecordNames?.join(', ') ?? filterValueAsString, - persistAction: 'NONE', - }; - }, - ), - ) - ).filter(isDefined); - }, - [ - apolloCoreClient, - filterQueryParams, - hasFiltersQueryParams, - objectMetadataItem.fields, - objectMetadataItems, - objectPermissionsByObjectMetadataId, - ], - ); - - return { - viewIdQueryParam, - hasFiltersQueryParams, - getFiltersFromQueryParams, - objectMetadataItem, - }; -}; diff --git a/packages/twenty-front/src/modules/views/schemas/filterUrlQueryParamsSchema.ts b/packages/twenty-front/src/modules/views/schemas/filterUrlQueryParamsSchema.ts new file mode 100644 index 00000000000..b7bbe9f9c3e --- /dev/null +++ b/packages/twenty-front/src/modules/views/schemas/filterUrlQueryParamsSchema.ts @@ -0,0 +1,17 @@ +import { ViewFilterOperand } from 'twenty-shared/types'; +import { relationFilterValueSchemaObject } from 'twenty-shared/utils'; +import z from 'zod'; +import { urlRecursiveFilterGroupSchema } from './urlRecursiveFilterGroupSchema'; + +export const filterUrlQueryParamsSchema = z.object({ + filter: z + .record( + z.string(), + z.partialRecord( + z.enum(ViewFilterOperand), + z.string().or(z.array(z.string())).or(relationFilterValueSchemaObject), + ), + ) + .optional(), + filterGroup: urlRecursiveFilterGroupSchema.optional(), +}); diff --git a/packages/twenty-front/src/modules/views/schemas/urlRecursiveFilterGroupSchema.ts b/packages/twenty-front/src/modules/views/schemas/urlRecursiveFilterGroupSchema.ts new file mode 100644 index 00000000000..3f1469260ab --- /dev/null +++ b/packages/twenty-front/src/modules/views/schemas/urlRecursiveFilterGroupSchema.ts @@ -0,0 +1,18 @@ +import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types'; +import z from 'zod'; +import { urlSingleFilterSchema } from './urlSingleFilterSchema'; + +type UrlRecursiveFilterGroupSchemaType = { + operator: RecordFilterGroupLogicalOperator; + filters?: z.infer[]; + groups?: UrlRecursiveFilterGroupSchemaType[]; +}; + +export const urlRecursiveFilterGroupSchema: z.ZodType = + z.lazy(() => + z.object({ + operator: z.enum(RecordFilterGroupLogicalOperator), + filters: z.array(urlSingleFilterSchema).optional(), + groups: z.array(urlRecursiveFilterGroupSchema).optional(), + }), + ); diff --git a/packages/twenty-front/src/modules/views/schemas/urlSingleFilterSchema.ts b/packages/twenty-front/src/modules/views/schemas/urlSingleFilterSchema.ts new file mode 100644 index 00000000000..2ae3b09deff --- /dev/null +++ b/packages/twenty-front/src/modules/views/schemas/urlSingleFilterSchema.ts @@ -0,0 +1,9 @@ +import { ViewFilterOperand } from 'twenty-shared/types'; +import z from 'zod'; + +export const urlSingleFilterSchema = z.object({ + field: z.string(), + op: z.enum(ViewFilterOperand), + value: z.string(), + subField: z.string().optional(), +}); diff --git a/packages/twenty-front/src/modules/views/types/UrlFilterDeserializationResult.ts b/packages/twenty-front/src/modules/views/types/UrlFilterDeserializationResult.ts new file mode 100644 index 00000000000..85ee33e9903 --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/UrlFilterDeserializationResult.ts @@ -0,0 +1,7 @@ +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; + +export type UrlFilterDeserializationResult = { + recordFilters: RecordFilter[]; + recordFilterGroups: RecordFilterGroup[]; +}; diff --git a/packages/twenty-front/src/modules/views/types/UrlRecursiveFilterGroup.ts b/packages/twenty-front/src/modules/views/types/UrlRecursiveFilterGroup.ts new file mode 100644 index 00000000000..99037d57a69 --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/UrlRecursiveFilterGroup.ts @@ -0,0 +1,6 @@ +import { type urlRecursiveFilterGroupSchema } from '@/views/schemas/urlRecursiveFilterGroupSchema'; +import { type z } from 'zod'; + +export type UrlRecursiveFilterGroup = z.infer< + typeof urlRecursiveFilterGroupSchema +>; diff --git a/packages/twenty-front/src/modules/views/types/UrlSingleFilter.ts b/packages/twenty-front/src/modules/views/types/UrlSingleFilter.ts new file mode 100644 index 00000000000..c434663b4ee --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/UrlSingleFilter.ts @@ -0,0 +1,4 @@ +import { type urlSingleFilterSchema } from '@/views/schemas/urlSingleFilterSchema'; +import { type z } from 'zod'; + +export type UrlSingleFilter = z.infer; diff --git a/packages/twenty-front/src/modules/views/utils/convertUrlSingleFilterToRecordFilter.ts b/packages/twenty-front/src/modules/views/utils/convertUrlSingleFilterToRecordFilter.ts new file mode 100644 index 00000000000..41beada7638 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/convertUrlSingleFilterToRecordFilter.ts @@ -0,0 +1,69 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { type UrlSingleFilter } from '@/views/types/UrlSingleFilter'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined, isExpectedSubFieldName } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +export const convertUrlSingleFilterToRecordFilter = ({ + urlSingleFilter, + objectMetadataItem, + recordFilterGroupId, + positionInGroup, +}: { + urlSingleFilter: UrlSingleFilter; + objectMetadataItem: ObjectMetadataItem; + recordFilterGroupId?: string; + positionInGroup: number; +}): RecordFilter | null => { + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === urlSingleFilter.field, + ); + + if (!isDefined(fieldMetadataItem)) { + return null; + } + + if (isNonEmptyString(urlSingleFilter.subField)) { + if (!isCompositeFieldType(fieldMetadataItem.type)) { + return null; + } + + if ( + !isExpectedSubFieldName( + fieldMetadataItem.type as Parameters[0], + urlSingleFilter.subField as Parameters< + typeof isExpectedSubFieldName + >[1], + urlSingleFilter.subField, + ) + ) { + return null; + } + } + + const displayValue = urlSingleFilter.value; + + const recordFilter: RecordFilter = { + id: uuidv4(), + fieldMetadataId: fieldMetadataItem.id, + value: urlSingleFilter.value, + displayValue, + type: fieldMetadataItem.type as RecordFilter['type'], + operand: urlSingleFilter.op, + label: fieldMetadataItem.label, + positionInRecordFilterGroup: positionInGroup, + }; + + if (isDefined(recordFilterGroupId)) { + recordFilter.recordFilterGroupId = recordFilterGroupId; + } + + if (isNonEmptyString(urlSingleFilter.subField)) { + recordFilter.subFieldName = + urlSingleFilter.subField as RecordFilter['subFieldName']; + } + + return recordFilter; +}; diff --git a/packages/twenty-front/src/modules/views/utils/deserializeUrlRecursiveFilterGroup.ts b/packages/twenty-front/src/modules/views/utils/deserializeUrlRecursiveFilterGroup.ts new file mode 100644 index 00000000000..3445ebeabef --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/deserializeUrlRecursiveFilterGroup.ts @@ -0,0 +1,85 @@ +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { type UrlFilterDeserializationResult } from '@/views/types/UrlFilterDeserializationResult'; +import { type UrlRecursiveFilterGroup } from '@/views/types/UrlRecursiveFilterGroup'; +import { convertUrlSingleFilterToRecordFilter } from '@/views/utils/convertUrlSingleFilterToRecordFilter'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; + +export const deserializeUrlRecursiveFilterGroup = ({ + urlRecursiveFilterGroup, + objectMetadataItem, + parentGroupId, + positionInParent, +}: { + urlRecursiveFilterGroup: UrlRecursiveFilterGroup; + objectMetadataItem: ObjectMetadataItem; + parentGroupId?: string; + positionInParent: number; +}): UrlFilterDeserializationResult => { + const groupId = uuidv4(); + const recordFilters: RecordFilter[] = []; + const recordFilterGroups: RecordFilterGroup[] = []; + const groupFilters: RecordFilter[] = []; + + let positionCounter = 0; + + if (isDefined(urlRecursiveFilterGroup.filters)) { + for (const urlSingleFilter of urlRecursiveFilterGroup.filters) { + const recordFilter = convertUrlSingleFilterToRecordFilter({ + urlSingleFilter, + objectMetadataItem, + recordFilterGroupId: groupId, + positionInGroup: positionCounter++, + }); + + if (isDefined(recordFilter)) { + groupFilters.push(recordFilter); + recordFilters.push(recordFilter); + } + } + } + + const childGroupResults: RecordFilterGroup[] = []; + if (isDefined(urlRecursiveFilterGroup.groups)) { + for (const childUrlGroup of urlRecursiveFilterGroup.groups) { + const childResult = deserializeUrlRecursiveFilterGroup({ + urlRecursiveFilterGroup: childUrlGroup, + objectMetadataItem, + parentGroupId: groupId, + positionInParent: positionCounter++, + }); + + if ( + childResult.recordFilters.length > 0 || + childResult.recordFilterGroups.length > 0 + ) { + recordFilters.push(...childResult.recordFilters); + childGroupResults.push(...childResult.recordFilterGroups); + recordFilterGroups.push(...childResult.recordFilterGroups); + } + } + } + + const hasContent = groupFilters.length > 0 || childGroupResults.length > 0; + + if (hasContent) { + const currentGroup: RecordFilterGroup = { + id: groupId, + logicalOperator: urlRecursiveFilterGroup.operator, + positionInRecordFilterGroup: positionInParent, + }; + + if (isDefined(parentGroupId)) { + currentGroup.parentRecordFilterGroupId = parentGroupId; + } + + recordFilterGroups.push(currentGroup); + } + + return { + recordFilters, + recordFilterGroups, + }; +}; diff --git a/packages/twenty-front/src/modules/views/utils/splitFieldNameIntoBaseAndSubField.ts b/packages/twenty-front/src/modules/views/utils/splitFieldNameIntoBaseAndSubField.ts new file mode 100644 index 00000000000..96153a9b9be --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/splitFieldNameIntoBaseAndSubField.ts @@ -0,0 +1,16 @@ +export const splitFieldNameIntoBaseAndSubField = ( + fieldName: string, +): { + baseFieldName: string; + subFieldName?: string; +} => { + const fieldParts = fieldName.split('.'); + const baseFieldName = fieldParts[0]; + const subFieldName = + fieldParts.length > 1 ? fieldParts.slice(1).join('.') : undefined; + + return { + baseFieldName, + subFieldName, + }; +};