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:
+165
@@ -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', () => {
|
||||
|
||||
+59
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user