Handle scalar select filter values when recomputing view filters (#22930)

## 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.
This commit is contained in:
Weiko
2026-07-16 14:52:10 +02:00
committed by GitHub
parent e9bc06a830
commit fdb78b35d0
4 changed files with 196 additions and 21 deletions
@@ -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<FieldMetadataType.SELECT>;
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: [],
});
});
});
@@ -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);
}
@@ -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) => {
@@ -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');
},
);
});