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'); }, ); });