Fixes greater than or equal and less than and equal filters (#13033)

This PR fixes a mismatch between the filter operation we have on NUMBER
and RATING field types, and the labels we use for those filters in the
application.

What is actually used is : 
- Greater than or equal
- Less than or equal

But unfortunately, until now we display "less than" and "greater than"
everywhere.

This PR fixes that.

We would still have to change the value that is saved in viewFilter
table from `greaterThan` to `greaterThanOrEqual` and likewise for less
than, but it would require a careful migration, and for now just
changing the display labels is enough.

See follow-up issue for migration of the DB values :
https://github.com/twentyhq/core-team-issues/issues/1196

Fixes https://github.com/twentyhq/twenty/issues/13000
This commit is contained in:
Lucas Bordeau
2025-07-04 16:26:19 +02:00
committed by GitHub
parent 8a5a9554d9
commit 0dcfca2ba3
14 changed files with 61 additions and 60 deletions

View File

@ -49,8 +49,8 @@ export const ObjectFilterDropdownFilterInput = ({
ViewFilterOperand.Is, ViewFilterOperand.Is,
ViewFilterOperand.IsNotNull, ViewFilterOperand.IsNotNull,
ViewFilterOperand.IsNot, ViewFilterOperand.IsNot,
ViewFilterOperand.LessThan, ViewFilterOperand.LessThanOrEqual,
ViewFilterOperand.GreaterThan, ViewFilterOperand.GreaterThanOrEqual,
ViewFilterOperand.IsBefore, ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter, ViewFilterOperand.IsAfter,
ViewFilterOperand.Contains, ViewFilterOperand.Contains,

View File

@ -15,14 +15,14 @@ const convertFieldRatingValueToNumber = (
rating: Exclude<FieldRatingValue, null>, rating: Exclude<FieldRatingValue, null>,
): string => rating.split('_')[1]; ): string => rating.split('_')[1];
export const convertGreaterThanRatingToArrayOfRatingValues = ( export const convertGreaterThanOrEqualRatingToArrayOfRatingValues = (
greaterThanValue: number, greaterThanValue: number,
) => ) =>
RATING_VALUES.filter( RATING_VALUES.filter(
(ratingValue) => +ratingValue.split('_')[1] >= greaterThanValue, (ratingValue) => +ratingValue.split('_')[1] >= greaterThanValue,
); );
export const convertLessThanRatingToArrayOfRatingValues = ( export const convertLessThanOrEqualRatingToArrayOfRatingValues = (
lessThanValue: number, lessThanValue: number,
) => ) =>
RATING_VALUES.filter( RATING_VALUES.filter(

View File

@ -1,14 +1,14 @@
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { getOperandLabel, getOperandLabelShort } from '../getOperandLabel';
import { capitalize } from 'twenty-shared/utils'; import { capitalize } from 'twenty-shared/utils';
import { getOperandLabel, getOperandLabelShort } from '../getOperandLabel';
describe('getOperandLabel', () => { describe('getOperandLabel', () => {
const testCases = [ const testCases = [
[ViewFilterOperand.Contains, 'Contains'], [ViewFilterOperand.Contains, 'Contains'],
[ViewFilterOperand.DoesNotContain, "Doesn't contain"], [ViewFilterOperand.DoesNotContain, "Doesn't contain"],
[ViewFilterOperand.GreaterThan, 'Greater than'], [ViewFilterOperand.GreaterThanOrEqual, 'Greater than or equal'],
[ViewFilterOperand.LessThan, 'Less than'], [ViewFilterOperand.LessThanOrEqual, 'Less than or equal'],
[ViewFilterOperand.Is, 'Is'], [ViewFilterOperand.Is, 'Is'],
[ViewFilterOperand.IsNot, 'Is not'], [ViewFilterOperand.IsNot, 'Is not'],
[ViewFilterOperand.IsNotNull, 'Is not null'], [ViewFilterOperand.IsNotNull, 'Is not null'],
@ -32,8 +32,8 @@ describe('getOperandLabelShort', () => {
[ViewFilterOperand.IsNot, ': Not'], [ViewFilterOperand.IsNot, ': Not'],
[ViewFilterOperand.DoesNotContain, ': Not'], [ViewFilterOperand.DoesNotContain, ': Not'],
[ViewFilterOperand.IsNotNull, ': NotNull'], [ViewFilterOperand.IsNotNull, ': NotNull'],
[ViewFilterOperand.GreaterThan, '\u00A0> '], [ViewFilterOperand.GreaterThanOrEqual, '\u00A0 '],
[ViewFilterOperand.LessThan, '\u00A0< '], [ViewFilterOperand.LessThanOrEqual, '\u00A0 '],
[undefined, ': '], // undefined operand [undefined, ': '], // undefined operand
]; ];

View File

@ -16,13 +16,13 @@ describe('getOperandsForFilterType', () => {
]; ];
const numberOperands = [ const numberOperands = [
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
]; ];
const currencyAmountMicrosOperands = [ const currencyAmountMicrosOperands = [
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
RecordFilterOperand.Is, RecordFilterOperand.Is,
RecordFilterOperand.IsNot, RecordFilterOperand.IsNot,
]; ];

View File

@ -6,8 +6,8 @@ describe('isFilterOperandExpectingValue', () => {
const testCases = [ const testCases = [
{ operand: ViewFilterOperand.Contains, expectedResult: true }, { operand: ViewFilterOperand.Contains, expectedResult: true },
{ operand: ViewFilterOperand.DoesNotContain, expectedResult: true }, { operand: ViewFilterOperand.DoesNotContain, expectedResult: true },
{ operand: ViewFilterOperand.GreaterThan, expectedResult: true }, { operand: ViewFilterOperand.GreaterThanOrEqual, expectedResult: true },
{ operand: ViewFilterOperand.LessThan, expectedResult: true }, { operand: ViewFilterOperand.LessThanOrEqual, expectedResult: true },
{ operand: ViewFilterOperand.Is, expectedResult: true }, { operand: ViewFilterOperand.Is, expectedResult: true },
{ operand: ViewFilterOperand.IsNot, expectedResult: true }, { operand: ViewFilterOperand.IsNot, expectedResult: true },
{ operand: ViewFilterOperand.IsRelative, expectedResult: true }, { operand: ViewFilterOperand.IsRelative, expectedResult: true },

View File

@ -4,8 +4,8 @@ export const configurableViewFilterOperands = new Set<ViewFilterOperand>([
ViewFilterOperand.Is, ViewFilterOperand.Is,
ViewFilterOperand.IsNotNull, ViewFilterOperand.IsNotNull,
ViewFilterOperand.IsNot, ViewFilterOperand.IsNot,
ViewFilterOperand.LessThan, ViewFilterOperand.LessThanOrEqual,
ViewFilterOperand.GreaterThan, ViewFilterOperand.GreaterThanOrEqual,
ViewFilterOperand.IsBefore, ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter, ViewFilterOperand.IsAfter,
ViewFilterOperand.Contains, ViewFilterOperand.Contains,

View File

@ -9,10 +9,10 @@ export const getOperandLabel = (
return t`Contains`; return t`Contains`;
case ViewFilterOperand.DoesNotContain: case ViewFilterOperand.DoesNotContain:
return t`Doesn't contain`; return t`Doesn't contain`;
case ViewFilterOperand.GreaterThan: case ViewFilterOperand.GreaterThanOrEqual:
return t`Greater than`; return t`Greater than or equal`;
case ViewFilterOperand.LessThan: case ViewFilterOperand.LessThanOrEqual:
return t`Less than`; return t`Less than or equal`;
case ViewFilterOperand.IsBefore: case ViewFilterOperand.IsBefore:
return t`Is before`; return t`Is before`;
case ViewFilterOperand.IsAfter: case ViewFilterOperand.IsAfter:
@ -56,10 +56,10 @@ export const getOperandLabelShort = (
return t`: NotEmpty`; return t`: NotEmpty`;
case ViewFilterOperand.IsEmpty: case ViewFilterOperand.IsEmpty:
return t`: Empty`; return t`: Empty`;
case ViewFilterOperand.GreaterThan: case ViewFilterOperand.GreaterThanOrEqual:
return '\u00A0> '; return '\u00A0 ';
case ViewFilterOperand.LessThan: case ViewFilterOperand.LessThanOrEqual:
return '\u00A0< '; return '\u00A0 ';
case ViewFilterOperand.IsBefore: case ViewFilterOperand.IsBefore:
return '\u00A0< '; return '\u00A0< ';
case ViewFilterOperand.IsAfter: case ViewFilterOperand.IsAfter:

View File

@ -12,8 +12,8 @@ export const isFilterOperandExpectingValue = (operand: ViewFilterOperand) => {
case ViewFilterOperand.IsNot: case ViewFilterOperand.IsNot:
case ViewFilterOperand.Contains: case ViewFilterOperand.Contains:
case ViewFilterOperand.DoesNotContain: case ViewFilterOperand.DoesNotContain:
case ViewFilterOperand.GreaterThan: case ViewFilterOperand.GreaterThanOrEqual:
case ViewFilterOperand.LessThan: case ViewFilterOperand.LessThanOrEqual:
case ViewFilterOperand.IsBefore: case ViewFilterOperand.IsBefore:
case ViewFilterOperand.IsAfter: case ViewFilterOperand.IsAfter:
case ViewFilterOperand.Is: case ViewFilterOperand.Is:

View File

@ -85,7 +85,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
value: '1000', value: '1000',
fieldMetadataId: companyMockEmployeesFieldMetadataId?.id, fieldMetadataId: companyMockEmployeesFieldMetadataId?.id,
displayValue: '1000', displayValue: '1000',
operand: ViewFilterOperand.GreaterThan, operand: ViewFilterOperand.GreaterThanOrEqual,
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
label: 'Employees', label: 'Employees',
}; };
@ -1119,7 +1119,7 @@ describe('should work as expected for the different field types', () => {
value: '1000', value: '1000',
fieldMetadataId: companyMockEmployeesFieldMetadataId?.id, fieldMetadataId: companyMockEmployeesFieldMetadataId?.id,
displayValue: '1000', displayValue: '1000',
operand: ViewFilterOperand.GreaterThan, operand: ViewFilterOperand.GreaterThanOrEqual,
label: 'Employees', label: 'Employees',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
}; };
@ -1129,7 +1129,7 @@ describe('should work as expected for the different field types', () => {
value: '1000', value: '1000',
fieldMetadataId: companyMockEmployeesFieldMetadataId?.id, fieldMetadataId: companyMockEmployeesFieldMetadataId?.id,
displayValue: '1000', displayValue: '1000',
operand: ViewFilterOperand.LessThan, operand: ViewFilterOperand.LessThanOrEqual,
label: 'Employees', label: 'Employees',
type: FieldMetadataType.NUMBER, type: FieldMetadataType.NUMBER,
}; };
@ -1205,7 +1205,7 @@ describe('should work as expected for the different field types', () => {
value: '1000', value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id, fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000', displayValue: '1000',
operand: RecordFilterOperand.GreaterThan, operand: RecordFilterOperand.GreaterThanOrEqual,
subFieldName: 'amountMicros' satisfies Extract< subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue, keyof FieldCurrencyValue,
'amountMicros' 'amountMicros'
@ -1219,7 +1219,7 @@ describe('should work as expected for the different field types', () => {
value: '1000', value: '1000',
fieldMetadataId: companyMockARRFieldMetadataId?.id, fieldMetadataId: companyMockARRFieldMetadataId?.id,
displayValue: '1000', displayValue: '1000',
operand: RecordFilterOperand.LessThan, operand: RecordFilterOperand.LessThanOrEqual,
subFieldName: 'amountMicros' satisfies Extract< subFieldName: 'amountMicros' satisfies Extract<
keyof FieldCurrencyValue, keyof FieldCurrencyValue,
'amountMicros' 'amountMicros'

View File

@ -24,8 +24,8 @@ import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateIL
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { import {
convertGreaterThanRatingToArrayOfRatingValues, convertGreaterThanOrEqualRatingToArrayOfRatingValues,
convertLessThanRatingToArrayOfRatingValues, convertLessThanOrEqualRatingToArrayOfRatingValues,
convertRatingToRatingValue, convertRatingToRatingValue,
} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput'; } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
@ -268,18 +268,18 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
eq: convertRatingToRatingValue(parseFloat(recordFilter.value)), eq: convertRatingToRatingValue(parseFloat(recordFilter.value)),
} as RatingFilter, } as RatingFilter,
}; };
case RecordFilterOperand.GreaterThan: case RecordFilterOperand.GreaterThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
in: convertGreaterThanRatingToArrayOfRatingValues( in: convertGreaterThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value), parseFloat(recordFilter.value),
), ),
} as RatingFilter, } as RatingFilter,
}; };
case RecordFilterOperand.LessThan: case RecordFilterOperand.LessThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
in: convertLessThanRatingToArrayOfRatingValues( in: convertLessThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value), parseFloat(recordFilter.value),
), ),
} as RatingFilter, } as RatingFilter,
@ -291,13 +291,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} }
case 'NUMBER': case 'NUMBER':
switch (recordFilter.operand) { switch (recordFilter.operand) {
case RecordFilterOperand.GreaterThan: case RecordFilterOperand.GreaterThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
gte: parseFloat(recordFilter.value), gte: parseFloat(recordFilter.value),
} as FloatFilter, } as FloatFilter,
}; };
case RecordFilterOperand.LessThan: case RecordFilterOperand.LessThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
lte: parseFloat(recordFilter.value), lte: parseFloat(recordFilter.value),
@ -401,13 +401,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
!isSubFieldFilter !isSubFieldFilter
) { ) {
switch (recordFilter.operand) { switch (recordFilter.operand) {
case RecordFilterOperand.GreaterThan: case RecordFilterOperand.GreaterThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
amountMicros: { gte: parseFloat(recordFilter.value) * 1000000 }, amountMicros: { gte: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter, } as CurrencyFilter,
}; };
case RecordFilterOperand.LessThan: case RecordFilterOperand.LessThanOrEqual:
return { return {
[correspondingFieldMetadataItem.name]: { [correspondingFieldMetadataItem.name]: {
amountMicros: { lte: parseFloat(recordFilter.value) * 1000000 }, amountMicros: { lte: parseFloat(recordFilter.value) * 1000000 },

View File

@ -69,13 +69,13 @@ export const FILTER_OPERANDS_MAP = {
...emptyOperands, ...emptyOperands,
], ],
CURRENCY: [ CURRENCY: [
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
...emptyOperands, ...emptyOperands,
], ],
NUMBER: [ NUMBER: [
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
...emptyOperands, ...emptyOperands,
], ],
RAW_JSON: [ RAW_JSON: [
@ -105,8 +105,8 @@ export const FILTER_OPERANDS_MAP = {
], ],
RATING: [ RATING: [
RecordFilterOperand.Is, RecordFilterOperand.Is,
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
...emptyOperands, ...emptyOperands,
], ],
RELATION: [...relationOperands, ...emptyOperands], RELATION: [...relationOperands, ...emptyOperands],
@ -139,8 +139,8 @@ export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = {
...emptyOperands, ...emptyOperands,
], ],
amountMicros: [ amountMicros: [
RecordFilterOperand.GreaterThan, RecordFilterOperand.GreaterThanOrEqual,
RecordFilterOperand.LessThan, RecordFilterOperand.LessThanOrEqual,
RecordFilterOperand.Is, RecordFilterOperand.Is,
RecordFilterOperand.IsNot, RecordFilterOperand.IsNot,
...emptyOperands, ...emptyOperands,

View File

@ -133,12 +133,12 @@ describe('buildValueFromFilter', () => {
describe('NUMBER field type', () => { describe('NUMBER field type', () => {
const testCases = [ const testCases = [
{ {
operand: ViewFilterOperand.GreaterThan, operand: ViewFilterOperand.GreaterThanOrEqual,
value: '5', value: '5',
expected: 6, expected: 6,
}, },
{ {
operand: ViewFilterOperand.LessThan, operand: ViewFilterOperand.LessThanOrEqual,
value: '5', value: '5',
expected: 4, expected: 4,
}, },
@ -359,12 +359,12 @@ describe('buildValueFromFilter', () => {
expected: undefined, expected: undefined,
}, },
{ {
operand: ViewFilterOperand.GreaterThan, operand: ViewFilterOperand.GreaterThanOrEqual,
value: 'Rating 1', value: 'Rating 1',
expected: 'RATING_2', expected: 'RATING_2',
}, },
{ {
operand: ViewFilterOperand.LessThan, operand: ViewFilterOperand.LessThanOrEqual,
value: 'Rating 2', value: 'Rating 2',
expected: 'RATING_1', expected: 'RATING_1',
}, },

View File

@ -150,9 +150,10 @@ const computeValueFromFilterNumber = (
value: string, value: string,
) => { ) => {
switch (operand) { switch (operand) {
case ViewFilterOperand.GreaterThan: //TODO: we shouln't create values from those filters as it makes no sense for the user
case ViewFilterOperand.GreaterThanOrEqual:
return Number(value) + 1; return Number(value) + 1;
case ViewFilterOperand.LessThan: case ViewFilterOperand.LessThanOrEqual:
return Number(value) - 1; return Number(value) - 1;
case ViewFilterOperand.IsNotEmpty: case ViewFilterOperand.IsNotEmpty:
return Number(value); return Number(value);
@ -205,13 +206,13 @@ const computeValueFromFilterRating = (
case ViewFilterOperand.Is: case ViewFilterOperand.Is:
case ViewFilterOperand.IsNotEmpty: case ViewFilterOperand.IsNotEmpty:
return option.value; return option.value;
case ViewFilterOperand.GreaterThan: { case ViewFilterOperand.GreaterThanOrEqual: {
const plusOne = options?.find( const plusOne = options?.find(
(opt) => opt.position === option.position + 1, (opt) => opt.position === option.position + 1,
)?.value; )?.value;
return plusOne ? plusOne : option.value; return plusOne ? plusOne : option.value;
} }
case ViewFilterOperand.LessThan: { case ViewFilterOperand.LessThanOrEqual: {
const minusOne = options?.find( const minusOne = options?.find(
(opt) => opt.position === option.position - 1, (opt) => opt.position === option.position - 1,
)?.value; )?.value;

View File

@ -2,8 +2,8 @@ export enum ViewFilterOperand {
Is = 'is', Is = 'is',
IsNotNull = 'isNotNull', IsNotNull = 'isNotNull',
IsNot = 'isNot', IsNot = 'isNot',
LessThan = 'lessThan', LessThanOrEqual = 'lessThan', // TODO: we could change this to 'lessThanOrEqual' for consistency but it would require a migration
GreaterThan = 'greaterThan', GreaterThanOrEqual = 'greaterThan', // TODO: we could change this to 'greaterThanOrEqual' for consistency but it would require a migration
IsBefore = 'isBefore', IsBefore = 'isBefore',
IsAfter = 'isAfter', IsAfter = 'isAfter',
Contains = 'contains', Contains = 'contains',