From fdb78b35d01508937de35700cc8467b56602793b Mon Sep 17 00:00:00 2001 From: Weiko Date: Thu, 16 Jul 2026 14:52:10 +0200 Subject: [PATCH] Handle scalar select filter values when recomputing view filters (#22930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixing `Internal Server Error: Unexpected invalid view filter value for filter` Updating a select field’s options failed with an INTERNAL_SERVER_ERROR when the field had an associated IS_NOT_EMPTY view filter. The affected filter stored an empty string as its value: ``` operand: IS_NOT_EMPTY value: "" ``` ### Root cause The option-update side effect treated every associated select filter value as an option array. It attempted to parse the empty string and then rejected the result because it was not an array. However, IS_NOT_EMPTY is a value-less operand, so its empty value is valid and should not participate in option recomputation. ### Fix Skip option-value recomputation for operands that do not expect a value, including IS_NOT_EMPTY. Normalize legacy scalar select-filter values using the same logic as filter validation. Leave subfield filters unchanged. Preserve compatibility with legacy filter-value representations until they are migrated to the canonical JSON format. --- ...field-metadata-options-update.util.spec.ts | 77 +++++++++++++ ...nvalid-select-filter-option-values.util.ts | 4 +- ...flat-field-metadata-options-update.util.ts | 28 ++--- ...filters-side-effect-v2.integration-spec.ts | 108 +++++++++++++++++- 4 files changed, 196 insertions(+), 21 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/recompute-view-filters-on-flat-field-metadata-options-update.util.spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/recompute-view-filters-on-flat-field-metadata-options-update.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/recompute-view-filters-on-flat-field-metadata-options-update.util.spec.ts new file mode 100644 index 0000000000..fb39531cbf --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/recompute-view-filters-on-flat-field-metadata-options-update.util.spec.ts @@ -0,0 +1,77 @@ +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util'; +import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { recomputeViewFiltersOnFlatFieldMetadataOptionsUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util'; +import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; + +const FILTER_ID = '11111111-1111-4111-8111-111111111111'; + +const fromFlatFieldMetadata = getFlatFieldMetadataMock({ + id: '22222222-2222-4222-8222-222222222222', + universalIdentifier: '22222222-2222-4222-8222-222222222222', + objectMetadataId: '33333333-3333-4333-8333-333333333333', + type: FieldMetadataType.SELECT, + options: [ + { + id: '44444444-4444-4444-8444-444444444444', + color: 'blue', + label: 'Shortlisted', + position: 0, + value: 'SHORTLISTED', + }, + ], + viewFilterIds: [FILTER_ID], +}) as FlatFieldMetadata; + +const toOptions = [ + { + ...fromFlatFieldMetadata.options![0], + label: 'Selected', + value: 'SELECTED', + }, +]; + +const recomputeForViewFilter = (viewFilter: FlatViewFilter) => + recomputeViewFiltersOnFlatFieldMetadataOptionsUpdate({ + fromFlatFieldMetadata, + toOptions, + flatViewFilterMaps: addFlatEntityToFlatEntityMapsOrThrow({ + flatEntity: viewFilter, + flatEntityMaps: createEmptyFlatEntityMaps(), + }), + }); + +describe('recomputeViewFiltersOnFlatFieldMetadataOptionsUpdate', () => { + it('updates a legacy scalar select filter value', () => { + const viewFilter = { + id: FILTER_ID, + universalIdentifier: FILTER_ID, + operand: ViewFilterOperand.IS, + subFieldName: null, + value: 'SHORTLISTED', + } as FlatViewFilter; + + expect(recomputeForViewFilter(viewFilter)).toEqual({ + flatViewFiltersToDelete: [], + flatViewFiltersToUpdate: [{ ...viewFilter, value: ['SELECTED'] }], + }); + }); + + it('leaves a value-less select filter unchanged', () => { + const viewFilter = { + id: FILTER_ID, + universalIdentifier: FILTER_ID, + operand: ViewFilterOperand.IS_NOT_EMPTY, + subFieldName: null, + value: '', + } as FlatViewFilter; + + expect(recomputeForViewFilter(viewFilter)).toEqual({ + flatViewFiltersToDelete: [], + flatViewFiltersToUpdate: [], + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts index 17584705ab..07aadec659 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util.ts @@ -8,9 +8,11 @@ import { import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type'; -const normalizeSelectFilterValues = ( +export const normalizeSelectFilterValues = ( value: ViewFilterValue | null, ): string[] => { + // TODO: Remove legacy scalar/stringified-value support after all view filter + // values have been migrated to their canonical JSON representation. if (isArray(value)) { return value.filter(isNonEmptyString); } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util.ts index 217a234fae..17f032ca28 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util.ts @@ -1,15 +1,15 @@ import { isNonEmptyString } from '@sniptt/guards'; import { type EnumFieldMetadataType } from 'twenty-shared/types'; -import { isDefined, parseJson } from 'twenty-shared/utils'; - import { - FieldMetadataException, - FieldMetadataExceptionCode, -} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; + isDefined, + isRecordFilterOperandExpectingValue, +} from 'twenty-shared/utils'; + import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { compareTwoFlatFieldMetadataEnumOptions } from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata-enum-options.util'; +import { normalizeSelectFilterValues } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-invalid-select-filter-option-values.util'; import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; type RecomputeViewFiltersOnFlatFieldMetadataOptionsUpdateArgs = { @@ -55,21 +55,15 @@ export const recomputeViewFiltersOnFlatFieldMetadataOptionsUpdate = ({ for (const viewFilter of flatViewFilters) { const rawViewFilterValue = viewFilter.value; - if (!isDefined(rawViewFilterValue)) { + if ( + !isDefined(rawViewFilterValue) || + !isRecordFilterOperandExpectingValue(viewFilter.operand) || + isNonEmptyString(viewFilter.subFieldName) + ) { continue; } - // TODO: all view filter value should be stored as JSON, this is ongoing work (we are missing a command to migrate the data) - const viewFilterValue = isNonEmptyString(rawViewFilterValue) - ? parseJson(rawViewFilterValue) - : rawViewFilterValue; - - if (!isDefined(viewFilterValue) || !Array.isArray(viewFilterValue)) { - throw new FieldMetadataException( - `Unexpected invalid view filter value for filter ${viewFilter.id}`, - FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR, - ); - } + const viewFilterValue = normalizeSelectFilterValues(rawViewFilterValue); const viewFilterOptions = viewFilterValue .flatMap((value) => { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts index 007befb596..61efafdf52 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts @@ -309,6 +309,110 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { }, ); + it('should update a related view filter with a scalar option value', async () => { + const { createOneField, createdView } = + await createObjectSelectFieldAndView({ + options: ALL_OPTIONS, + type: fieldType, + }); + + const { + data: { createViewFilter: createdViewFilter }, + } = await createOneViewFilter({ + input: { + viewId: createdView.id, + fieldMetadataId: createOneField.id, + operand: operandForFieldType, + value: ALL_OPTIONS[0].value, + }, + expectToFail: false, + gqlFields: ` + id + `, + }); + + const optionsWithIds = createOneField.options; + + if (!isDefined(optionsWithIds)) { + throw new Error('optionsWithIds is not defined'); + } + + const updatedOption = fakeOptionUpdate(optionsWithIds[0]); + + await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: createOneField.id, + updatePayload: { + options: [updatedOption, ...optionsWithIds.slice(1)], + }, + }, + gqlFields: ` + id + `, + }); + + const updatedViewFilter = await findViewFilterWithRestApi( + createdViewFilter.id, + ); + + expect(updatedViewFilter?.value).toEqual([updatedOption.value]); + }); + + it('should leave a value-less view filter unchanged', async () => { + const { createOneField, createdView } = + await createObjectSelectFieldAndView({ + options: ALL_OPTIONS, + type: fieldType, + }); + + const { + data: { createViewFilter: createdViewFilter }, + } = await createOneViewFilter({ + input: { + viewId: createdView.id, + fieldMetadataId: createOneField.id, + operand: ViewFilterOperand.IS_NOT_EMPTY, + value: '', + }, + expectToFail: false, + gqlFields: ` + id + `, + }); + + const optionsWithIds = createOneField.options; + + if (!isDefined(optionsWithIds)) { + throw new Error('optionsWithIds is not defined'); + } + + await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: createOneField.id, + updatePayload: { + options: [ + fakeOptionUpdate(optionsWithIds[0]), + ...optionsWithIds.slice(1), + ], + }, + }, + gqlFields: ` + id + `, + }); + + const updatedViewFilter = await findViewFilterWithRestApi( + createdViewFilter.id, + ); + + expect(updatedViewFilter).toMatchObject({ + operand: ViewFilterOperand.IS_NOT_EMPTY, + value: '', + }); + }); + const failingTestCases: EachTestingContext<{ createViewFilterValue: unknown; }>[] = [ @@ -347,9 +451,7 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { }); expect(errors).toBeDefined(); - expect(errors![0].extensions.code).toBe( - 'METADATA_VALIDATION_FAILED', - ); + expect(errors![0].extensions.code).toBe('METADATA_VALIDATION_FAILED'); }, ); });