[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
This commit is contained in:
Raphaël Bosi
2025-12-23 17:00:38 +01:00
committed by GitHub
parent b1aaf21356
commit 1bc344c6fa
3 changed files with 236 additions and 22 deletions
@@ -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"]');
});
});
});
@@ -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 [
{
@@ -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;
};