Handle relative date step filter (#13930)

https://github.com/user-attachments/assets/2fbb02dd-2170-4807-a1dd-faa3f374bd5a



- move relative date types to twenty-shared
- use and adapt existing relative date picker to be used in workflow
forms
- add backend logic to support relative dates in filters
This commit is contained in:
Thomas Trompette
2025-08-18 14:12:22 +02:00
committed by GitHub
parent 483c1e2214
commit 578a2f4e6b
21 changed files with 1366 additions and 36 deletions
@@ -0,0 +1,590 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
subDays,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import { type VariableDateViewFilterValue } from 'twenty-shared/types';
import {
evaluateRelativeDateFilter,
parseAndEvaluateRelativeDateFilter,
} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util';
describe('Relative Date Filter Utils', () => {
const now = new Date('2024-01-15T12:00:00Z'); // Monday, January 15, 2024 at noon
beforeEach(() => {
// Mock Date constructor to return a fixed date for consistent testing
jest.useFakeTimers();
jest.setSystemTime(now);
});
afterEach(() => {
jest.useRealTimers();
});
describe('parseAndEvaluateRelativeDateFilter', () => {
describe('Edge cases', () => {
it('should handle invalid JSON gracefully', () => {
const invalidJson = 'invalid json';
expect(
parseAndEvaluateRelativeDateFilter({
dateToCheck: now,
relativeDateString: invalidJson,
}),
).toBe(false);
});
it('should handle missing direction gracefully', () => {
const relativeDateString = JSON.stringify({
amount: 1,
unit: 'DAY',
});
expect(
parseAndEvaluateRelativeDateFilter({
dateToCheck: now,
relativeDateString,
}),
).toBe(false);
});
it('should handle missing unit gracefully', () => {
const relativeDateString = JSON.stringify({
direction: 'NEXT',
amount: 1,
});
expect(
parseAndEvaluateRelativeDateFilter({
dateToCheck: now,
relativeDateString,
}),
).toBe(false);
});
it('should handle unknown direction gracefully', () => {
const relativeDateString = JSON.stringify({
direction: 'UNKNOWN',
amount: 1,
unit: 'DAY',
});
expect(
parseAndEvaluateRelativeDateFilter({
dateToCheck: now,
relativeDateString,
}),
).toBe(false);
});
it('should handle unknown unit gracefully', () => {
const relativeDateString = JSON.stringify({
direction: 'NEXT',
amount: 1,
unit: 'UNKNOWN',
});
expect(
parseAndEvaluateRelativeDateFilter({
dateToCheck: now,
relativeDateString,
}),
).toBe(false);
});
});
});
describe('evaluateRelativeDateFilter', () => {
describe('NEXT direction', () => {
it('should return true for dates within the next N days', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'NEXT',
amount: 3,
unit: 'DAY',
};
// Dates within the next 3 days should match
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 2),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 3),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 4),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the next N weeks', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'NEXT',
amount: 2,
unit: 'WEEK',
};
// Dates within the next 2 weeks should match
expect(
evaluateRelativeDateFilter({
dateToCheck: addWeeks(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 10),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: addWeeks(now, 3),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the next N months', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'NEXT',
amount: 2,
unit: 'MONTH',
};
// Dates within the next 2 months should match
expect(
evaluateRelativeDateFilter({
dateToCheck: addMonths(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 45),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: addMonths(now, 3),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the next N years', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'NEXT',
amount: 1,
unit: 'YEAR',
};
// Dates within the next year should match
expect(
evaluateRelativeDateFilter({
dateToCheck: addMonths(now, 6),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: addMonths(now, 11),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: addYears(now, 2),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return false when amount is undefined', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'NEXT',
unit: 'DAY',
};
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
});
describe('PAST direction', () => {
it('should return true for dates within the past N days', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'PAST',
amount: 3,
unit: 'DAY',
};
// Dates within the past 3 days should match
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 2),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 3),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 4),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the past N weeks', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'PAST',
amount: 2,
unit: 'WEEK',
};
// Dates within the past 2 weeks should match
expect(
evaluateRelativeDateFilter({
dateToCheck: subWeeks(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 10),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: subWeeks(now, 3),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the past N months', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'PAST',
amount: 2,
unit: 'MONTH',
};
// Dates within the past 2 months should match
expect(
evaluateRelativeDateFilter({
dateToCheck: subMonths(now, 1),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 45),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: subMonths(now, 3),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within the past N years', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'PAST',
amount: 1,
unit: 'YEAR',
};
// Dates within the past year should match
expect(
evaluateRelativeDateFilter({
dateToCheck: subMonths(now, 6),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: subMonths(now, 11),
relativeDateFilterValue,
}),
).toBe(true);
// Dates outside the range should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: subYears(now, 2),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: addDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return false when amount is undefined', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'PAST',
unit: 'DAY',
};
expect(
evaluateRelativeDateFilter({
dateToCheck: subDays(now, 1),
relativeDateFilterValue,
}),
).toBe(false);
});
});
describe('THIS direction', () => {
it('should return true for dates within this day', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'THIS',
unit: 'DAY',
};
// Same day should match
expect(
evaluateRelativeDateFilter({
dateToCheck: now,
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-15T08:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
// Different days should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-14T20:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within this week', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'THIS',
unit: 'WEEK',
};
expect(
evaluateRelativeDateFilter({
dateToCheck: now,
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-14T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-20T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
// Different weeks should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-13T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-21T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within this month', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'THIS',
unit: 'MONTH',
};
// Same month should match
expect(
evaluateRelativeDateFilter({
dateToCheck: now,
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-01T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-31T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
// Different months should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2023-12-31T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-02-01T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within this year', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
direction: 'THIS',
unit: 'YEAR',
};
// Same year should match
expect(
evaluateRelativeDateFilter({
dateToCheck: now,
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-01T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-12-31T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
// Different years should not match
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2023-12-31T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2025-01-01T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
});
});
});
});
@@ -5,6 +5,8 @@ import {
ViewFilterOperand,
} from 'twenty-shared/types';
import { parseAndEvaluateRelativeDateFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util';
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
@@ -153,11 +155,13 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean {
function evaluateDateFilter(filter: ResolvedFilter): boolean {
const dateLeftValue = new Date(String(filter.leftOperand));
const dateRightValue = new Date(String(filter.rightOperand));
switch (filter.operand) {
case ViewFilterOperand.Is:
return dateLeftValue.getDate() === dateRightValue.getDate();
return (
dateLeftValue.getDate() ===
new Date(String(filter.rightOperand)).getDate()
);
case ViewFilterOperand.IsInPast:
return dateLeftValue.getTime() < Date.now();
@@ -168,10 +172,16 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
return dateLeftValue.toDateString() === new Date().toDateString();
case ViewFilterOperand.IsBefore:
return dateLeftValue.getTime() < dateRightValue.getTime();
return (
dateLeftValue.getTime() <
new Date(String(filter.rightOperand)).getTime()
);
case ViewFilterOperand.IsAfter:
return dateLeftValue.getTime() > dateRightValue.getTime();
return (
dateLeftValue.getTime() >
new Date(String(filter.rightOperand)).getTime()
);
case ViewFilterOperand.IsEmpty:
return (
@@ -187,6 +197,12 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
filter.leftOperand !== ''
);
case ViewFilterOperand.IsRelative:
return parseAndEvaluateRelativeDateFilter({
dateToCheck: dateLeftValue,
relativeDateString: String(filter.rightOperand),
});
default:
throw new Error(
`Operand ${filter.operand} not supported for date filter`,
@@ -0,0 +1,182 @@
import {
addDays,
addMonths,
addWeeks,
addYears,
endOfDay,
endOfMonth,
endOfWeek,
endOfYear,
isWithinInterval,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import {
type VariableDateViewFilterValue,
type VariableDateViewFilterValueUnit,
} from 'twenty-shared/types';
import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils';
export const parseAndEvaluateRelativeDateFilter = ({
dateToCheck,
relativeDateString,
}: {
dateToCheck: Date;
relativeDateString: string;
}): boolean => {
const relativeDateFilterValue =
safeParseRelativeDateFilterValue(relativeDateString);
if (!relativeDateFilterValue) {
return false;
}
return evaluateRelativeDateFilter({
dateToCheck,
relativeDateFilterValue,
});
};
export const evaluateRelativeDateFilter = ({
dateToCheck,
relativeDateFilterValue,
}: {
dateToCheck: Date;
relativeDateFilterValue: VariableDateViewFilterValue;
}): boolean => {
const now = new Date();
switch (relativeDateFilterValue.direction) {
case 'NEXT':
return evaluateNextDirection(dateToCheck, relativeDateFilterValue, now);
case 'THIS':
return evaluateThisDirection(dateToCheck, relativeDateFilterValue, now);
case 'PAST':
return evaluatePastDirection(dateToCheck, relativeDateFilterValue, now);
default:
return false;
}
};
const evaluateNextDirection = (
dateToCheck: Date,
relativeDateFilterValue: VariableDateViewFilterValue,
now: Date,
): boolean => {
if (relativeDateFilterValue.amount === undefined) {
return false;
}
const { amount, unit } = relativeDateFilterValue;
const endOfPeriod = addUnitToDate(now, amount, unit);
if (!endOfPeriod) {
return false;
}
return isWithinInterval(dateToCheck, {
start: now,
end: endOfPeriod,
});
};
function evaluatePastDirection(
dateToCheck: Date,
relativeDateFilterValue: VariableDateViewFilterValue,
now: Date,
): boolean {
if (relativeDateFilterValue.amount === undefined) {
return false;
}
const { amount, unit } = relativeDateFilterValue;
const startOfPeriod = subtractUnitFromDate(now, amount, unit);
if (!startOfPeriod) {
return false;
}
return isWithinInterval(dateToCheck, {
start: startOfPeriod,
end: now,
});
}
function evaluateThisDirection(
dateToCheck: Date,
relativeDateValue: VariableDateViewFilterValue,
now: Date,
): boolean {
const { unit } = relativeDateValue;
switch (unit) {
case 'DAY':
return isWithinInterval(dateToCheck, {
start: startOfDay(now),
end: endOfDay(now),
});
case 'WEEK':
return isWithinInterval(dateToCheck, {
start: startOfWeek(now),
end: endOfWeek(now),
});
case 'MONTH':
return isWithinInterval(dateToCheck, {
start: startOfMonth(now),
end: endOfMonth(now),
});
case 'YEAR':
return isWithinInterval(dateToCheck, {
start: startOfYear(now),
end: endOfYear(now),
});
default:
return false;
}
}
function addUnitToDate(
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
): Date | null {
switch (unit) {
case 'DAY':
return addDays(date, amount);
case 'WEEK':
return addWeeks(date, amount);
case 'MONTH':
return addMonths(date, amount);
case 'YEAR':
return addYears(date, amount);
default:
return null;
}
}
function subtractUnitFromDate(
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
): Date | null {
switch (unit) {
case 'DAY':
return subDays(date, amount);
case 'WEEK':
return subWeeks(date, amount);
case 'MONTH':
return subMonths(date, amount);
case 'YEAR':
return subYears(date, amount);
default:
return null;
}
}