use Temporal for date filter evaluation and fix IS operand (#22408)
## Summary - Refactored `evaluateDateFilter` in the workflow filter action from native `Date` to the Temporal API, using the shared `parseToInstantOrThrow` and `isSamePlainDate` utilities from `twenty-shared` (resolving the long-standing `// TODO: refactor this with Temporal`). - Fixed a bug in the `IS` operand: it previously compared only `getDate()` (day-of-month 1–31), so e.g. `2023-01-15` incorrectly matched `2023-02-15`. It now compares the full calendar day in UTC. - Removed server-local-timezone leakage: `IS_TODAY` and day comparisons now run in UTC (consistent with the neighbouring relative-date filter util), instead of relying on `toDateString()`/`getDate()`. ## Behavior changes (intended) - `IS` now matches on the full UTC calendar day, not day-of-month. - `DATE_TIME` `IS` matches on the same UTC day (not exact-instant equality). - Date comparisons are UTC-based, so evaluations near midnight in a non-UTC server locale may differ from the old local-timezone behavior. - Parsing is stricter (ISO + known formats via `parseToInstantOrThrow`); malformed operands resolve to "no match" instead of being loosely guessed by `new Date()`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22408?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+107
-11
@@ -887,10 +887,12 @@ describe('evaluateFilterConditions', () => {
|
||||
});
|
||||
|
||||
describe('date operands', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
|
||||
const today = new Date();
|
||||
const now = new Date().toISOString();
|
||||
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); // 1 day ago
|
||||
const futureDate = new Date(
|
||||
Date.now() + 24 * 60 * 60 * 1000,
|
||||
).toISOString(); // 1 day from now
|
||||
const today = new Date().toISOString();
|
||||
|
||||
it('should handle IsInPast operand correctly', () => {
|
||||
const filter = createFilter(
|
||||
@@ -997,22 +999,19 @@ describe('evaluateFilterConditions', () => {
|
||||
});
|
||||
|
||||
it('should handle Is operand for dates correctly', () => {
|
||||
const sameDate1 = new Date('2023-01-15');
|
||||
const sameDate2 = new Date('2023-01-15');
|
||||
const filter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
sameDate1,
|
||||
sameDate2,
|
||||
'2023-01-15',
|
||||
'2023-01-15',
|
||||
'DATE',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
|
||||
|
||||
const otherDate = new Date('2023-01-16');
|
||||
const differentFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
sameDate1,
|
||||
otherDate,
|
||||
'2023-01-15',
|
||||
'2023-01-16',
|
||||
'DATE',
|
||||
);
|
||||
|
||||
@@ -1021,6 +1020,103 @@ describe('evaluateFilterConditions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match Is when dates share the day of month but differ in month or year', () => {
|
||||
const januaryFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
'2023-01-15',
|
||||
'2023-02-15',
|
||||
'DATE',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [januaryFilter] })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const yearFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
'2023-01-15',
|
||||
'2024-01-15',
|
||||
'DATE',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [yearFilter] })).toBe(false);
|
||||
});
|
||||
|
||||
it('should match Is on the same UTC day for date-time values', () => {
|
||||
const sameDayFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
'2023-01-15T08:30:00.000Z',
|
||||
'2023-01-15T21:45:00.000Z',
|
||||
'DATE_TIME',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [sameDayFilter] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Date instance operands (database-event trigger passes raw ORM records)', () => {
|
||||
const isFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
new Date('2023-01-15T08:30:00.000Z'),
|
||||
new Date('2023-01-15T21:45:00.000Z'),
|
||||
'DATE_TIME',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(true);
|
||||
|
||||
const beforeFilter = createFilter(
|
||||
ViewFilterOperand.IS_BEFORE,
|
||||
new Date('2023-01-15T00:00:00.000Z'),
|
||||
new Date('2023-01-16T00:00:00.000Z'),
|
||||
'DATE_TIME',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const invalidFilter = createFilter(
|
||||
ViewFilterOperand.IS_BEFORE,
|
||||
new Date('invalid'),
|
||||
new Date('2023-01-16T00:00:00.000Z'),
|
||||
'DATE_TIME',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [invalidFilter] })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match Is or Before/After when the right operand is an invalid date', () => {
|
||||
const isFilter = createFilter(
|
||||
ViewFilterOperand.IS,
|
||||
'2023-01-15',
|
||||
'not-a-date',
|
||||
'DATE',
|
||||
);
|
||||
const beforeFilter = createFilter(
|
||||
ViewFilterOperand.IS_BEFORE,
|
||||
'2023-01-15',
|
||||
'not-a-date',
|
||||
'DATE',
|
||||
);
|
||||
const afterFilter = createFilter(
|
||||
ViewFilterOperand.IS_AFTER,
|
||||
'2023-01-15',
|
||||
'not-a-date',
|
||||
'DATE',
|
||||
);
|
||||
|
||||
expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(false);
|
||||
expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(evaluateFilterConditions({ filters: [afterFilter] })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle date IsEmpty and IsNotEmpty operands', () => {
|
||||
const emptyFilter = createFilter(
|
||||
ViewFilterOperand.IS_EMPTY,
|
||||
|
||||
+52
-18
@@ -4,6 +4,7 @@ import {
|
||||
isObject,
|
||||
isString,
|
||||
} from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
type StepFilter,
|
||||
type StepFilterGroup,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
import {
|
||||
convertViewFilterOperandToCoreOperand as convertViewFilterOperandDeprecated,
|
||||
isDefined,
|
||||
isSamePlainDate,
|
||||
parseToInstantOrThrow,
|
||||
} from 'twenty-shared/utils';
|
||||
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
|
||||
|
||||
@@ -214,8 +217,23 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function parseDateOperandToInstant(value: unknown): Temporal.Instant | null {
|
||||
try {
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
||||
return Temporal.Instant.fromEpochMilliseconds(value.getTime());
|
||||
}
|
||||
|
||||
return parseToInstantOrThrow(String(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toUtcPlainDate(instant: Temporal.Instant): Temporal.PlainDate {
|
||||
return instant.toZonedDateTimeISO('UTC').toPlainDate();
|
||||
}
|
||||
|
||||
function evaluateDateFilter(filter: ResolvedFilter): boolean {
|
||||
// TODO: refactor this with Temporal
|
||||
if (filter.operand === ViewFilterOperand.IS_EMPTY) {
|
||||
return !isDefined(filter.leftOperand) || filter.leftOperand === '';
|
||||
}
|
||||
@@ -224,42 +242,58 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
|
||||
return isDefined(filter.leftOperand) && filter.leftOperand !== '';
|
||||
}
|
||||
|
||||
const dateLeftValue = new Date(String(filter.leftOperand));
|
||||
const leftInstant = parseDateOperandToInstant(filter.leftOperand);
|
||||
|
||||
if (Number.isNaN(dateLeftValue.getTime())) {
|
||||
if (!isDefined(leftInstant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filter.operand) {
|
||||
case ViewFilterOperand.IS:
|
||||
case ViewFilterOperand.IS: {
|
||||
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
|
||||
|
||||
return (
|
||||
dateLeftValue.getDate() ===
|
||||
new Date(String(filter.rightOperand)).getDate()
|
||||
isDefined(rightInstant) &&
|
||||
isSamePlainDate(
|
||||
toUtcPlainDate(leftInstant),
|
||||
toUtcPlainDate(rightInstant),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
case ViewFilterOperand.IS_IN_PAST:
|
||||
return dateLeftValue.getTime() < Date.now();
|
||||
return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) < 0;
|
||||
|
||||
case ViewFilterOperand.IS_IN_FUTURE:
|
||||
return dateLeftValue.getTime() > Date.now();
|
||||
return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) > 0;
|
||||
|
||||
case ViewFilterOperand.IS_TODAY:
|
||||
return dateLeftValue.toDateString() === new Date().toDateString();
|
||||
|
||||
case ViewFilterOperand.IS_BEFORE:
|
||||
return (
|
||||
dateLeftValue.getTime() <
|
||||
new Date(String(filter.rightOperand)).getTime()
|
||||
return isSamePlainDate(
|
||||
toUtcPlainDate(leftInstant),
|
||||
Temporal.Now.zonedDateTimeISO('UTC').toPlainDate(),
|
||||
);
|
||||
|
||||
case ViewFilterOperand.IS_AFTER:
|
||||
case ViewFilterOperand.IS_BEFORE: {
|
||||
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
|
||||
|
||||
return (
|
||||
dateLeftValue.getTime() >
|
||||
new Date(String(filter.rightOperand)).getTime()
|
||||
isDefined(rightInstant) &&
|
||||
Temporal.Instant.compare(leftInstant, rightInstant) < 0
|
||||
);
|
||||
}
|
||||
|
||||
case ViewFilterOperand.IS_AFTER: {
|
||||
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
|
||||
|
||||
return (
|
||||
isDefined(rightInstant) &&
|
||||
Temporal.Instant.compare(leftInstant, rightInstant) > 0
|
||||
);
|
||||
}
|
||||
|
||||
case ViewFilterOperand.IS_RELATIVE:
|
||||
return parseAndEvaluateRelativeDateFilter({
|
||||
dateToCheck: dateLeftValue,
|
||||
dateToCheck: new Date(leftInstant.epochMilliseconds),
|
||||
relativeDateString: String(filter.rightOperand),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user