Add empty operands to UUID filter type in workflow filter action (#20821)

## Summary
- Adds `IS_EMPTY` and `IS_NOT_EMPTY` operands to the UUID entry in
`getStepFilterOperands`, aligning the workflow filter action with the
find records (search) action which already includes these operands for
ID-type fields.

## Test plan
- [ ] Open a workflow with a filter action, select an ID-type field, and
verify the operand dropdown now includes "Is empty" and "Is not empty"
- [ ] Open a workflow with a find records action, select an ID-type
field, and verify the operand dropdown is consistent with the filter
action

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas Trompette
2026-05-21 21:06:10 +02:00
committed by GitHub
parent a66fae79ee
commit 138eb5a74a
13 changed files with 1046 additions and 84 deletions
@@ -0,0 +1,238 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getRecordFilterOperands ACTOR returns ACTOR (text-like) operands when subFieldName is undefined 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands ACTOR returns IS/IS_NOT operands when subFieldName is source (actor source subField) 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands ACTOR returns IS/IS_NOT operands when subFieldName is workspaceMemberId (actor wsMember subField) 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands CURRENCY returns amountMicros operands when subFieldName is amountMicros 1`] = `
[
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands CURRENCY returns amountMicros operands when subFieldName is undefined 1`] = `
[
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands CURRENCY returns currencyCode operands when subFieldName is currencyCode 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=ADDRESS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=ARRAY 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=BOOLEAN 1`] = `
[
"IS",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=DATE 1`] = `
[
"IS",
"IS_RELATIVE",
"IS_IN_PAST",
"IS_IN_FUTURE",
"IS_TODAY",
"IS_BEFORE",
"IS_AFTER",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=DATE_TIME 1`] = `
[
"IS",
"IS_RELATIVE",
"IS_IN_PAST",
"IS_IN_FUTURE",
"IS_TODAY",
"IS_BEFORE",
"IS_AFTER",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=EMAILS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=FILES 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=FULL_NAME 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=LINKS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=MULTI_SELECT 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=NUMBER 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=PHONES 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=RATING 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=RAW_JSON 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=RELATION 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=SELECT 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=TEXT 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=TS_VECTOR 1`] = `
[
"VECTOR_SEARCH",
]
`;
exports[`getRecordFilterOperands returns the expected operands for filterType=UUID 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
@@ -0,0 +1,96 @@
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
const filterTypesWithoutSubField: FilterableAndTSVectorFieldType[] = [
'TEXT',
'EMAILS',
'FULL_NAME',
'ADDRESS',
'LINKS',
'PHONES',
'NUMBER',
'RAW_JSON',
'FILES',
'DATE_TIME',
'DATE',
'RATING',
'RELATION',
'MULTI_SELECT',
'SELECT',
'ARRAY',
'BOOLEAN',
'UUID',
'TS_VECTOR',
];
describe('getRecordFilterOperands', () => {
it.each(filterTypesWithoutSubField)(
'returns the expected operands for filterType=%s',
(filterType) => {
const operands = getRecordFilterOperands({
filterType,
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
},
);
describe('CURRENCY', () => {
it('returns currencyCode operands when subFieldName is currencyCode', () => {
const operands = getRecordFilterOperands({
filterType: 'CURRENCY',
subFieldName: 'currencyCode',
});
expect(operands).toMatchSnapshot();
});
it('returns amountMicros operands when subFieldName is amountMicros', () => {
const operands = getRecordFilterOperands({
filterType: 'CURRENCY',
subFieldName: 'amountMicros',
});
expect(operands).toMatchSnapshot();
});
it('returns amountMicros operands when subFieldName is undefined', () => {
const operands = getRecordFilterOperands({
filterType: 'CURRENCY',
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
});
});
describe('ACTOR', () => {
it('returns IS/IS_NOT operands when subFieldName is source (actor source subField)', () => {
const operands = getRecordFilterOperands({
filterType: 'ACTOR',
subFieldName: 'source',
});
expect(operands).toMatchSnapshot();
});
it('returns IS/IS_NOT operands when subFieldName is workspaceMemberId (actor wsMember subField)', () => {
const operands = getRecordFilterOperands({
filterType: 'ACTOR',
subFieldName: 'workspaceMemberId',
});
expect(operands).toMatchSnapshot();
});
it('returns ACTOR (text-like) operands when subFieldName is undefined', () => {
const operands = getRecordFilterOperands({
filterType: 'ACTOR',
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
});
});
});
@@ -112,6 +112,7 @@ export const FILTER_OPERANDS_MAP = {
],
RATING: [
RecordFilterOperand.IS,
RecordFilterOperand.IS_NOT,
RecordFilterOperand.GREATER_THAN_OR_EQUAL,
RecordFilterOperand.LESS_THAN_OR_EQUAL,
...emptyOperands,
@@ -139,7 +140,7 @@ export const FILTER_OPERANDS_MAP = {
],
BOOLEAN: [RecordFilterOperand.IS],
TS_VECTOR: [RecordFilterOperand.VECTOR_SEARCH],
UUID: [RecordFilterOperand.IS],
UUID: [RecordFilterOperand.IS, RecordFilterOperand.IS_NOT, ...emptyOperands],
} as const satisfies FilterOperandMap;
export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = {
@@ -167,6 +167,7 @@ const computeValueFromFilterRating = (
)?.value;
return minusOne ?? option.value;
}
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
@@ -275,6 +276,10 @@ const computeValueFromFilterUUID = (
switch (operand) {
case ViewFilterOperand.IS:
return value;
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_EMPTY:
case ViewFilterOperand.IS_NOT_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
@@ -0,0 +1,295 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getStepFilterOperands ACTOR returns RELATION operands when subFieldName is workspaceMemberId 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands ACTOR returns SELECT operands when subFieldName is source 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands ACTOR returns TEXT operands when subFieldName is undefined 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands ACTOR returns TEXT operands when subFieldName is unknown 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands CURRENCY returns amountMicros operands when subFieldName is amountMicros 1`] = `
[
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands CURRENCY returns amountMicros operands when subFieldName is undefined 1`] = `
[
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands CURRENCY returns currencyCode operands when subFieldName is currencyCode 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=ADDRESS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=ARRAY 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=BOOLEAN 1`] = `
[
"IS",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=DATE 1`] = `
[
"IS",
"IS_RELATIVE",
"IS_IN_PAST",
"IS_IN_FUTURE",
"IS_TODAY",
"IS_BEFORE",
"IS_AFTER",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=DATE_TIME 1`] = `
[
"IS",
"IS_RELATIVE",
"IS_IN_PAST",
"IS_IN_FUTURE",
"IS_TODAY",
"IS_BEFORE",
"IS_AFTER",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=EMAILS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=FULL_NAME 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=LINKS 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=MULTI_SELECT 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=NUMBER 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=NUMERIC 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=PHONES 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=RATING 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=RAW_JSON 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=RELATION 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=SELECT 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=TEXT 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=UNKNOWN_TYPE_FALLS_THROUGH 1`] = `
[
"IS",
"IS_NOT",
"CONTAINS",
"DOES_NOT_CONTAIN",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=UUID 1`] = `
[
"IS",
"IS_NOT",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=array 1`] = `
[
"CONTAINS",
"DOES_NOT_CONTAIN",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=boolean 1`] = `
[
"IS",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=number 1`] = `
[
"IS",
"IS_NOT",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
exports[`getStepFilterOperands returns the expected operands for filterType=undefined 1`] = `
[
"IS",
"IS_NOT",
"CONTAINS",
"DOES_NOT_CONTAIN",
"GREATER_THAN_OR_EQUAL",
"LESS_THAN_OR_EQUAL",
"IS_EMPTY",
"IS_NOT_EMPTY",
]
`;
@@ -0,0 +1,108 @@
import { getStepFilterOperands } from '@/workflow/workflow-steps/filters/utils/getStepFilterOperands';
const filterTypesWithoutSubField = [
'TEXT',
'EMAILS',
'FULL_NAME',
'ADDRESS',
'LINKS',
'PHONES',
'NUMBER',
'number',
'RAW_JSON',
'DATE_TIME',
'DATE',
'RATING',
'RELATION',
'MULTI_SELECT',
'SELECT',
'ARRAY',
'array',
'BOOLEAN',
'boolean',
'UUID',
'NUMERIC',
'UNKNOWN_TYPE_FALLS_THROUGH',
undefined,
] as const;
describe('getStepFilterOperands', () => {
it.each(filterTypesWithoutSubField)(
'returns the expected operands for filterType=%s',
(filterType) => {
const operands = getStepFilterOperands({
filterType,
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
},
);
describe('CURRENCY', () => {
it('returns currencyCode operands when subFieldName is currencyCode', () => {
const operands = getStepFilterOperands({
filterType: 'CURRENCY',
subFieldName: 'currencyCode',
});
expect(operands).toMatchSnapshot();
});
it('returns amountMicros operands when subFieldName is amountMicros', () => {
const operands = getStepFilterOperands({
filterType: 'CURRENCY',
subFieldName: 'amountMicros',
});
expect(operands).toMatchSnapshot();
});
it('returns amountMicros operands when subFieldName is undefined', () => {
const operands = getStepFilterOperands({
filterType: 'CURRENCY',
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
});
});
describe('ACTOR', () => {
it('returns SELECT operands when subFieldName is source', () => {
const operands = getStepFilterOperands({
filterType: 'ACTOR',
subFieldName: 'source',
});
expect(operands).toMatchSnapshot();
});
it('returns RELATION operands when subFieldName is workspaceMemberId', () => {
const operands = getStepFilterOperands({
filterType: 'ACTOR',
subFieldName: 'workspaceMemberId',
});
expect(operands).toMatchSnapshot();
});
it('returns TEXT operands when subFieldName is unknown', () => {
const operands = getStepFilterOperands({
filterType: 'ACTOR',
subFieldName: 'someUnknownSubField',
});
expect(operands).toMatchSnapshot();
});
it('returns TEXT operands when subFieldName is undefined', () => {
const operands = getStepFilterOperands({
filterType: 'ACTOR',
subFieldName: undefined,
});
expect(operands).toMatchSnapshot();
});
});
});
@@ -1,15 +1,9 @@
import {
COMPOSITE_FIELD_FILTER_OPERANDS_MAP,
FILTER_OPERANDS_MAP,
} from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { ViewFilterOperand } from 'twenty-shared/types';
const emptyOperands = [
ViewFilterOperand.IS_EMPTY,
ViewFilterOperand.IS_NOT_EMPTY,
] as const;
const relationOperands = [
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
] as const;
const defaultOperands = [
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
@@ -17,78 +11,10 @@ const defaultOperands = [
ViewFilterOperand.DOES_NOT_CONTAIN,
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
ViewFilterOperand.LESS_THAN_OR_EQUAL,
...emptyOperands,
ViewFilterOperand.IS_EMPTY,
ViewFilterOperand.IS_NOT_EMPTY,
] as const;
export const FILTER_OPERANDS_MAP = {
TEXT: [
ViewFilterOperand.CONTAINS,
ViewFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
NUMBER: [
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
ViewFilterOperand.LESS_THAN_OR_EQUAL,
...emptyOperands,
],
RAW_JSON: [
ViewFilterOperand.CONTAINS,
ViewFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
DATE_TIME: [
ViewFilterOperand.IS,
ViewFilterOperand.IS_IN_PAST,
ViewFilterOperand.IS_IN_FUTURE,
ViewFilterOperand.IS_TODAY,
ViewFilterOperand.IS_BEFORE,
ViewFilterOperand.IS_AFTER,
ViewFilterOperand.IS_RELATIVE,
...emptyOperands,
],
RATING: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands],
RELATION: [...relationOperands, ...emptyOperands],
MULTI_SELECT: [
ViewFilterOperand.CONTAINS,
ViewFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
SELECT: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands],
ARRAY: [
ViewFilterOperand.CONTAINS,
ViewFilterOperand.DOES_NOT_CONTAIN,
...emptyOperands,
],
BOOLEAN: [ViewFilterOperand.IS],
UUID: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT],
NUMERIC: [
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
ViewFilterOperand.LESS_THAN_OR_EQUAL,
...emptyOperands,
],
};
export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = {
CURRENCY: {
currencyCode: [
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
...emptyOperands,
],
amountMicros: [
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
ViewFilterOperand.LESS_THAN_OR_EQUAL,
ViewFilterOperand.IS,
ViewFilterOperand.IS_NOT,
...emptyOperands,
],
},
};
export const getStepFilterOperands = ({
filterType,
subFieldName,
@@ -136,7 +62,7 @@ export const getStepFilterOperands = ({
case 'UUID':
return FILTER_OPERANDS_MAP.UUID;
case 'NUMERIC':
return FILTER_OPERANDS_MAP.NUMERIC;
return FILTER_OPERANDS_MAP.NUMBER;
case 'ACTOR': {
if (subFieldName === 'source') {
return FILTER_OPERANDS_MAP.SELECT;
@@ -310,6 +310,171 @@ describe('evaluateFilterConditions', () => {
'Operand CONTAINS not supported for uuid filter',
);
});
it('should return true for IS_EMPTY when UUID is null/undefined/empty', () => {
const cases = [null, undefined, ''];
for (const leftOperand of cases) {
const filter = createFilter(
ViewFilterOperand.IS_EMPTY,
leftOperand,
null,
'UUID',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
}
});
it('should return false for IS_EMPTY when UUID is a non-empty string', () => {
const filter = createFilter(
ViewFilterOperand.IS_EMPTY,
uuid1,
null,
'UUID',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
});
it('should return false for IS_NOT_EMPTY when UUID is null/undefined/empty', () => {
const cases = [null, undefined, ''];
for (const leftOperand of cases) {
const filter = createFilter(
ViewFilterOperand.IS_NOT_EMPTY,
leftOperand,
null,
'UUID',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
}
});
it('should return true for IS_NOT_EMPTY when UUID is a non-empty string', () => {
const filter = createFilter(
ViewFilterOperand.IS_NOT_EMPTY,
uuid1,
null,
'UUID',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
});
});
describe('Rating filter operands', () => {
it('should compare RATING_n strings by numeric rank for IS', () => {
const filterMatch = createFilter(
ViewFilterOperand.IS,
'RATING_3',
'3',
'RATING',
);
const filterNoMatch = createFilter(
ViewFilterOperand.IS,
'RATING_3',
'4',
'RATING',
);
expect(evaluateFilterConditions({ filters: [filterMatch] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filterNoMatch] })).toBe(
false,
);
});
it('should compare with GREATER_THAN_OR_EQUAL on rank', () => {
const filterGte = createFilter(
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
'RATING_4',
'3',
'RATING',
);
const filterLt = createFilter(
ViewFilterOperand.GREATER_THAN_OR_EQUAL,
'RATING_2',
'3',
'RATING',
);
expect(evaluateFilterConditions({ filters: [filterGte] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filterLt] })).toBe(false);
});
it('should compare with LESS_THAN_OR_EQUAL on rank', () => {
const filterLte = createFilter(
ViewFilterOperand.LESS_THAN_OR_EQUAL,
'RATING_2',
'3',
'RATING',
);
const filterGt = createFilter(
ViewFilterOperand.LESS_THAN_OR_EQUAL,
'RATING_4',
'3',
'RATING',
);
expect(evaluateFilterConditions({ filters: [filterLte] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filterGt] })).toBe(false);
});
it('should handle IS_NOT, IS_EMPTY, IS_NOT_EMPTY', () => {
const filterIsNotMatch = createFilter(
ViewFilterOperand.IS_NOT,
'RATING_3',
'4',
'RATING',
);
const filterIsEmpty = createFilter(
ViewFilterOperand.IS_EMPTY,
null,
null,
'RATING',
);
const filterIsNotEmpty = createFilter(
ViewFilterOperand.IS_NOT_EMPTY,
'RATING_3',
null,
'RATING',
);
expect(evaluateFilterConditions({ filters: [filterIsNotMatch] })).toBe(
true,
);
expect(evaluateFilterConditions({ filters: [filterIsEmpty] })).toBe(
true,
);
expect(evaluateFilterConditions({ filters: [filterIsNotEmpty] })).toBe(
true,
);
});
it('should treat unparseable rating string as empty', () => {
const filter = createFilter(
ViewFilterOperand.IS_EMPTY,
'not-a-rating',
null,
'RATING',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
});
it('should throw on unsupported rating operand', () => {
const filter = createFilter(
ViewFilterOperand.CONTAINS,
'RATING_3',
'3',
'RATING',
);
expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow(
'Operand CONTAINS not supported for rating filter',
);
});
});
describe('Select filter operands', () => {
@@ -75,6 +75,8 @@ function evaluateFilter(
return evaluateBooleanFilter(filterWithConvertedOperand);
case 'UUID':
return evaluateUuidFilter(filterWithConvertedOperand);
case 'RATING':
return evaluateRatingFilter(filterWithConvertedOperand);
case 'RELATION':
return evaluateRelationFilter(filterWithConvertedOperand);
case 'CURRENCY':
@@ -268,6 +270,10 @@ function evaluateUuidFilter(filter: ResolvedFilter): boolean {
return filter.leftOperand === filter.rightOperand;
case ViewFilterOperand.IS_NOT:
return filter.leftOperand !== filter.rightOperand;
case ViewFilterOperand.IS_EMPTY:
return !isNonEmptyString(filter.leftOperand);
case ViewFilterOperand.IS_NOT_EMPTY:
return isNonEmptyString(filter.leftOperand);
default:
throw new Error(
`Operand ${filter.operand} not supported for uuid filter`,
@@ -275,6 +281,59 @@ function evaluateUuidFilter(filter: ResolvedFilter): boolean {
}
}
// Rating values are stored as enum strings 'RATING_1'..'RATING_5'. Comparisons
// must operate on the numeric rank (1..5), not lexicographic order or Number()
// on the enum string (which would be NaN).
function parseRatingRank(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.length > 0) {
const ratingPrefixMatch = value.match(/^RATING_(\d+)$/);
if (ratingPrefixMatch !== null) {
return Number(ratingPrefixMatch[1]);
}
const numericValue = Number(value);
if (Number.isFinite(numericValue)) {
return numericValue;
}
}
return undefined;
}
function evaluateRatingFilter(filter: ResolvedFilter): boolean {
const leftRank = parseRatingRank(filter.leftOperand);
const rightRank = parseRatingRank(filter.rightOperand);
switch (filter.operand) {
case ViewFilterOperand.IS:
return isDefined(leftRank) && leftRank === rightRank;
case ViewFilterOperand.IS_NOT:
return !isDefined(leftRank) || leftRank !== rightRank;
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
return (
isDefined(leftRank) && isDefined(rightRank) && leftRank >= rightRank
);
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
return (
isDefined(leftRank) && isDefined(rightRank) && leftRank <= rightRank
);
case ViewFilterOperand.IS_EMPTY:
return !isDefined(leftRank);
case ViewFilterOperand.IS_NOT_EMPTY:
return isDefined(leftRank);
default:
throw new Error(
`Operand ${filter.operand} not supported for rating filter`,
);
}
}
function evaluateRelationFilter(filter: ResolvedFilter): boolean {
// compare only the ids. If the left operand is the relation object, get the id
const leftValue =
@@ -484,6 +484,18 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('rating.in');
});
it('should handle IS_NOT operand as wrapped eq', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: makeFilter('f-rating', RecordFilterOperand.IS_NOT, '3'),
fieldMetadataItemById,
});
expect(result).toEqual({
not: { rating: { eq: 'RATING_3' } },
});
});
});
describe('BOOLEAN filter', () => {
@@ -920,6 +932,24 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('recordId.in');
});
it('should handle IS_NOT operand as wrapped in', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: makeFilter(
'f-uuid',
RecordFilterOperand.IS_NOT,
'["550e8400-e29b-41d4-a716-446655440000"]',
),
fieldMetadataItemById,
});
expect(result).toEqual({
not: {
recordId: { in: ['550e8400-e29b-41d4-a716-446655440000'] },
},
});
});
});
describe('relation traversal', () => {
@@ -507,6 +507,14 @@ const buildDirectFieldGqlOperationFilter = ({
eq: convertRatingToRatingValue(parseFloat(recordFilter.value)),
} as RatingFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[fieldMetadataItem.name]: {
eq: convertRatingToRatingValue(parseFloat(recordFilter.value)),
} as RatingFilter,
},
};
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[fieldMetadataItem.name]: {
@@ -1525,9 +1533,16 @@ const buildDirectFieldGqlOperationFilter = ({
};
}
case 'UUID': {
const recordIds = arrayOfUuidOrVariableSchema.parse(recordFilter.value);
const parsedRecordIds = arrayOfUuidOrVariableSchema.parse(
recordFilter.value,
);
if (!isDefined(recordIds) || recordIds.length === 0) return;
// Fall back to a sentinel v4 UUID when the input isn't a valid UUID so the
// filter compiles to a guaranteed no-match instead of being silently dropped.
const recordIds =
isDefined(parsedRecordIds) && parsedRecordIds.length > 0
? parsedRecordIds
: ['00000000-0000-4000-8000-000000000000'];
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
@@ -1536,6 +1551,14 @@ const buildDirectFieldGqlOperationFilter = ({
in: recordIds,
} as UUIDFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[fieldMetadataItem.name]: {
in: recordIds,
} as UUIDFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
@@ -97,6 +97,16 @@ describe('getEmptyRecordGqlOperationFilter', () => {
});
});
it('should handle UUID type', () => {
const result = getEmptyRecordGqlOperationFilter(
makeParams(FieldMetadataType.UUID),
);
expect(result).toEqual({
testField: { is: 'NULL' },
});
});
it('should handle CURRENCY type', () => {
const result = getEmptyRecordGqlOperationFilter(
makeParams(FieldMetadataType.CURRENCY),
@@ -15,6 +15,7 @@ import {
type RelationFilter,
type SelectFilter,
type StringFilter,
type UUIDFilter,
} from '@/types';
import { CustomError } from '@/utils/errors';
import { computeEmptyGqlOperationFilterForEmails } from '@/utils/filter/computeEmptyGqlOperationFilterForEmails';
@@ -308,6 +309,11 @@ export const getEmptyRecordGqlOperationFilter = ({
[correspondingField.name]: { is: 'NULL' } as SelectFilter,
};
break;
case 'UUID':
emptyRecordFilter = {
[correspondingField.name]: { is: 'NULL' } as UUIDFilter,
};
break;
case 'MULTI_SELECT':
emptyRecordFilter = {
or: [