From 1bc344c6fad9b1be017495e3c224dfd73327add5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:00:38 +0100 Subject: [PATCH] [DASHBOARDS] Fix filter parsing (#16782) ## Fix filter parsing for charts Fixes https://github.com/twentyhq/twenty/issues/16606 Fixes https://github.com/twentyhq/private-issues/issues/396 When clicking on a chart slice/bar grouped by certain field types, the filter value was passed as a plain string instead of a JSON array, causing a JSON parse error on navigation. This happens because `arrayOfStringsOrVariablesSchema` in the filter parsing logic expects JSON array format for certain field types, but the values weren't wrapped correctly for all cases. Extracted the util `formatChartFilterValue` and renamed it to `serializeChartBucketValueForFilter` and modified it to handle JSON array wrapping for: - CURRENCY fields with currencyCode subfield (IS operand) - MULTI_SELECT fields (CONTAINS operand) - ADDRESS fields with addressCountry subfield (CONTAINS operand) Added unit tests for the new utility ### Before https://github.com/user-attachments/assets/9a52572b-e896-445a-9f5c-e21963f78441 ### After https://github.com/user-attachments/assets/12eed5e5-a49c-4557-a45c-ac7e00b3422a --- ...serializeChartBucketValueForFilter.test.ts | 189 ++++++++++++++++++ .../graph/utils/buildFilterFromChartBucket.ts | 27 +-- .../serializeChartBucketValueForFilter.ts | 42 ++++ 3 files changed, 236 insertions(+), 22 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/serializeChartBucketValueForFilter.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/serializeChartBucketValueForFilter.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/serializeChartBucketValueForFilter.test.ts new file mode 100644 index 0000000000..04accf631e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/serializeChartBucketValueForFilter.test.ts @@ -0,0 +1,189 @@ +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +import { serializeChartBucketValueForFilter } from '../serializeChartBucketValueForFilter'; + +describe('serializeChartBucketValueForFilter', () => { + describe('fields requiring JSON array with IS operand', () => { + it('should wrap SELECT field value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('["option1"]'); + }); + + it('should wrap UUID field value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.UUID, + bucketRawValue: '123e4567-e89b-12d3-a456-426614174000', + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('["123e4567-e89b-12d3-a456-426614174000"]'); + }); + + it('should wrap RELATION field value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.RELATION, + bucketRawValue: 'relation-id', + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('["relation-id"]'); + }); + + it('should wrap CURRENCY currencyCode subfield value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.CURRENCY, + bucketRawValue: 'EUR', + operand: ViewFilterOperand.IS, + subFieldName: 'currencyCode', + }); + + expect(result).toBe('["EUR"]'); + }); + + it('should not wrap CURRENCY amountMicros subfield value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.CURRENCY, + bucketRawValue: '1000000', + operand: ViewFilterOperand.IS, + subFieldName: 'amountMicros', + }); + + expect(result).toBe('1000000'); + }); + }); + + describe('fields requiring JSON array with CONTAINS operand', () => { + it('should wrap MULTI_SELECT field value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.MULTI_SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.CONTAINS, + }); + + expect(result).toBe('["option1"]'); + }); + + it('should wrap ADDRESS addressCountry subfield value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.ADDRESS, + bucketRawValue: 'US', + operand: ViewFilterOperand.CONTAINS, + subFieldName: 'addressCountry', + }); + + expect(result).toBe('["US"]'); + }); + + it('should not wrap ADDRESS addressStreet1 subfield value in JSON array', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.ADDRESS, + bucketRawValue: '123 Main St', + operand: ViewFilterOperand.CONTAINS, + subFieldName: 'addressStreet1', + }); + + expect(result).toBe('123 Main St'); + }); + }); + + describe('fields not requiring JSON array', () => { + it('should not wrap TEXT field value', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.TEXT, + bucketRawValue: 'some text', + operand: ViewFilterOperand.CONTAINS, + }); + + expect(result).toBe('some text'); + }); + + it('should not wrap NUMBER field value', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.NUMBER, + bucketRawValue: '42', + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('42'); + }); + + it('should not wrap BOOLEAN field value', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.BOOLEAN, + bucketRawValue: 'true', + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('true'); + }); + + it('should not wrap SELECT field with non-IS operand', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.IS_NOT, + }); + + expect(result).toBe('option1'); + }); + + it('should not wrap MULTI_SELECT field with non-CONTAINS operand', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.MULTI_SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.DOES_NOT_CONTAIN, + }); + + expect(result).toBe('option1'); + }); + }); + + describe('value conversion', () => { + it('should convert number to string', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.NUMBER, + bucketRawValue: 123, + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('123'); + }); + + it('should convert boolean to string', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.BOOLEAN, + bucketRawValue: true, + operand: ViewFilterOperand.IS, + }); + + expect(result).toBe('true'); + }); + + it('should handle null subFieldName', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.IS, + subFieldName: null, + }); + + expect(result).toBe('["option1"]'); + }); + + it('should handle undefined subFieldName', () => { + const result = serializeChartBucketValueForFilter({ + fieldType: FieldMetadataType.SELECT, + bucketRawValue: 'option1', + operand: ViewFilterOperand.IS, + subFieldName: undefined, + }); + + expect(result).toBe('["option1"]'); + }); + }); +}); 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 index 5ca0dc5d74..38b5c5ad78 100644 --- 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 @@ -6,6 +6,7 @@ import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/ut import { buildDateRangeFiltersForGranularity } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity'; import { isCyclicalDateGranularity } from '@/page-layout/widgets/graph/utils/isCyclicalDateGranularity'; import { isTimeRangeDateGranularity } from '@/page-layout/widgets/graph/utils/isTimeRangeDateGranularity'; +import { serializeChartBucketValueForFilter } from '@/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter'; import { isNonEmptyString } from '@sniptt/guards'; import { ObjectRecordGroupByDateGranularity, @@ -16,7 +17,6 @@ import { isDefined, isFieldMetadataDateKind, } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; type ChartFilter = { fieldName: string; @@ -32,24 +32,6 @@ type BuildFilterFromChartBucketParams = { timezone?: string; }; -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, @@ -126,11 +108,12 @@ export const buildFilterFromChartBucket = ({ const operand = availableOperands[0]; - const value = formatChartFilterValue( - fieldMetadataItem.type, + const value = serializeChartBucketValueForFilter({ + fieldType: fieldMetadataItem.type, bucketRawValue, operand, - ); + subFieldName, + }); return [ { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter.ts new file mode 100644 index 0000000000..42a9a625ea --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter.ts @@ -0,0 +1,42 @@ +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +type SerializeChartBucketValueForFilterParams = { + fieldType: FieldMetadataType; + bucketRawValue: unknown; + operand: ViewFilterOperand; + subFieldName?: string | null; +}; + +export const serializeChartBucketValueForFilter = ({ + fieldType, + bucketRawValue, + operand, + subFieldName, +}: SerializeChartBucketValueForFilterParams): string => { + const stringValue = String(bucketRawValue); + + const isCurrencyCodeSubField = + fieldType === FieldMetadataType.CURRENCY && subFieldName === 'currencyCode'; + + const isAddressCountrySubField = + fieldType === FieldMetadataType.ADDRESS && + subFieldName === 'addressCountry'; + + const needsJsonArrayWithIsOperand = + operand === ViewFilterOperand.IS && + ([ + FieldMetadataType.SELECT, + FieldMetadataType.UUID, + FieldMetadataType.RELATION, + ].includes(fieldType) || + isCurrencyCodeSubField); + + const needsJsonArrayWithContainsOperand = + operand === ViewFilterOperand.CONTAINS && + (fieldType === FieldMetadataType.MULTI_SELECT || isAddressCountrySubField); + + const needsJsonArray = + needsJsonArrayWithIsOperand || needsJsonArrayWithContainsOperand; + + return needsJsonArray ? JSON.stringify([stringValue]) : stringValue; +};