fix(filter): resolve filter-derived date defaults with Temporal (fixes create/update crash in date-filtered views) (#22124)

## Symptom

Creating or updating an Opportunity on a board view crashes with:

```
Uncaught TypeError: e.split is not a function
    at splitDateString (date-fns) → parseISO → isMatchingDateFilter
    → isRecordMatchingFilter → <group-by optimistic effect> → createOneRecord
```

`e` is a non-string (a `Date` object, shown as `{}` in the debugger).
Distinct from the `null` case fixed in #22029.

## Root cause

Reproduced on a live board filtered by **Close date — Is relative —
"This quarter"** (a `DATE_TIME` field).

When you create a record in a filtered view, `useCreateNewIndexRecord`
derives default field values from the view's filters via
`buildRecordInputFromFilter` → `buildValueFromFilter`. For a date field,
`computeValueFromFilterDate` returned a JS **`Date` object** (`new
Date()` / `new Date(value)`), assigned to the new record verbatim. The
optimistic record's `closeDate` was then a `Date`, not an ISO string,
and the group-by optimistic effect matched it via `parseISO(dateObject)`
→ `dateString.split is not a function`, crashing every create/update in
the view.

## Fix

`computeValueFromFilterDate` now returns timezone-aware **ISO strings**
via Temporal, mirroring what `turnRecordFilterIntoGqlOperationFilter`
produces for the same filters — so a record created in a date-filtered
view actually satisfies its own filter:

- **`DATE_TIME`** → an instant. Date-only filter values (a `DATE_TIME`
"is" filter stores `yyyy-MM-dd`, no time) are resolved to the start of
day in the user's time zone, matching how the filter operands are built
— `Temporal.Instant.from()` alone would `RangeError` on them.
- **`DATE`** → a plain date `yyyy-MM-dd` resolved in the **user's time
zone** (`Temporal.Now.plainDateISO(timeZone)`). A bare `new
Date().toISOString()` would use the UTC date, which near midnight is the
wrong calendar day for non-UTC users, so the new record could miss its
own `IS_TODAY`/relative filter. The time zone is threaded from
`useUserTimezone` (same source the filter side uses).
- `IS_BEFORE` subtracts 1 day for `DATE` / 1 minute for `DATE_TIME` (the
`-1 minute` special-case moved out of `buildRecordInputFromFilter`,
which now just assigns the string).

No `Date` object ever reaches `parseISO`, and the value matches the
filter operand, so the optimistic card lands in the right place.

## Tests

- `buildValueFromFilter.spec.ts`: every date operator returns an ISO
**string** (round-tripped to the expected instant); a `DATE` block
asserts date-only `yyyy-MM-dd` output and that `IS_TODAY` resolves to
the correct calendar day **per time zone** at a UTC day boundary
(`2024-03-20` UTC vs `2024-03-21` Asia/Tokyo); a date-only `DATE_TIME`
`IS` value resolves to start-of-day in the user time zone (UTC vs
America/New_York) without throwing.
- `buildRecordInputFromFilter.test.ts`: filter-derived date values are
ISO strings, not `Date` objects.

## Verified locally (Chrome)

Reproduced the exact prod scenario on the **By Stage Opportunities
Kanban board** (the group-by optimistic effect) with a **Close date — Is
relative** filter:

- Created a card in a column → **no `split is not a function` crash**;
the card got a valid Close date (`now` for the relative filter, e.g. `25
Jun 2026 13:10`). Console clean.
- Also verified a **table view + Close date — Is** filter: created
record gets a valid start-of-day value (`25 Jun 2026 00:00`).

Both `IS_RELATIVE` (→ now instant) and the date-only `IS` (→
start-of-day in the user tz) paths produce string values that the
optimistic matcher handles without throwing.
This commit is contained in:
Charles Bochet
2026-06-25 14:51:42 +02:00
committed by GitHub
parent fe1a8ad5f0
commit d8cb4aa15b
6 changed files with 158 additions and 35 deletions
@@ -14,6 +14,7 @@ import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/b
import { buildCompositeValueFromSubField } from '@/object-record/record-table/utils/buildValueFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefined } from '@sniptt/guards';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
@@ -29,6 +30,8 @@ export const useBuildRecordInputFromRLSPredicates = ({
}) => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { userTimezone } = useUserTimezone();
const { record: currentWorkspaceMemberRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: currentWorkspaceMember?.id,
@@ -177,6 +180,7 @@ export const useBuildRecordInputFromRLSPredicates = ({
currentRecordFilters: staticFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
timeZone: userTimezone,
});
const mergedRecordInput: Partial<ObjectRecord> = {
@@ -5,6 +5,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
@@ -25,12 +26,15 @@ export const useBuildRecordInputFromFilters = ({
const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
const { userTimezone } = useUserTimezone();
const buildRecordInputFromFilters = (): Partial<ObjectRecord> => {
return buildRecordInputFromFilter({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
currentRecordId,
timeZone: userTimezone,
});
};
@@ -62,6 +62,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result).toEqual({ companyName: 'Acme' });
@@ -80,9 +81,13 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result.createdAt).toEqual(new Date('2025-06-15T10:29:00.000Z'));
expect(typeof result.createdAt).toBe('string');
expect(new Date(result.createdAt as string).getTime()).toBe(
new Date('2025-06-15T10:29:00.000Z').getTime(),
);
});
it('should not subtract a minute for DATE_TIME with IS operand', () => {
@@ -98,9 +103,33 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result.createdAt).toEqual(filterDate);
expect(typeof result.createdAt).toBe('string');
expect(new Date(result.createdAt as string).getTime()).toBe(
filterDate.getTime(),
);
});
it('should assign an ISO string (not a Date) for DATE_TIME with IS_RELATIVE operand', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_DATE_TIME,
type: 'DATE_TIME',
operand: ViewFilterOperand.IS_RELATIVE,
value: 'THIS_QUARTER',
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(typeof result.createdAt).toBe('string');
expect(() =>
new Date(result.createdAt as string).toISOString(),
).not.toThrow();
});
it('should deep-merge a single composite address sub-field starting from empty object', () => {
@@ -115,6 +144,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result.address).toEqual({ addressCity: 'Paris' });
@@ -140,6 +170,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result.address).toEqual({
@@ -159,6 +190,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result).toEqual({});
@@ -175,6 +207,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result).toEqual({});
@@ -191,6 +224,7 @@ describe('buildRecordInputFromFilter', () => {
}),
],
objectMetadataItem: mockObjectMetadataItem,
timeZone: 'UTC',
});
expect(result).toEqual({ revenue: 42 });
@@ -73,65 +73,123 @@ describe('buildValueFromFilter', () => {
{
operand: ViewFilterOperand.IS,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_AFTER,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_BEFORE,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime() - 60_000,
},
{
operand: ViewFilterOperand.IS_IN_PAST,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_IN_FUTURE,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_TODAY,
value: '',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_RELATIVE,
value: '',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
{
operand: ViewFilterOperand.IS_EMPTY,
value: '',
expected: undefined,
expectedMs: undefined,
},
{
operand: ViewFilterOperand.IS_NOT_EMPTY,
value: '2024-03-20T12:00:00Z',
expected: mockDate,
expectedMs: mockDate.getTime(),
},
];
it.each(testCases)(
'should handle $operand with value "$value"',
({ operand, value, expected }) => {
'should return an ISO string for $operand',
({ operand, value, expectedMs }) => {
const filter = createTestFilter(operand, value, 'DATE_TIME');
const result = buildValueFromFilter({ filter });
if (expected instanceof Date) {
expect(result).toBeInstanceOf(Date);
expect(result).toEqual(expected);
if (expectedMs === undefined) {
expect(result).toBeUndefined();
} else {
expect(result).toBe(expected);
expect(typeof result).toBe('string');
expect(new Date(result as string).getTime()).toBe(expectedMs);
}
},
);
});
describe('DATE_TIME field type with a date-only filter value', () => {
it('resolves a date-only IS value to the start of day in the user time zone', () => {
const filter = createTestFilter(
ViewFilterOperand.IS,
'2024-03-20',
'DATE_TIME',
);
const utcResult = buildValueFromFilter({ filter, timeZone: 'UTC' });
expect(typeof utcResult).toBe('string');
expect(new Date(utcResult as string).getTime()).toBe(
new Date('2024-03-20T00:00:00Z').getTime(),
);
const nyResult = buildValueFromFilter({
filter,
timeZone: 'America/New_York',
});
expect(new Date(nyResult as string).getTime()).toBe(
new Date('2024-03-20T04:00:00Z').getTime(),
);
});
});
describe('DATE field type', () => {
it('returns a date-only string (not a Date or instant) for IS', () => {
const filter = createTestFilter(
ViewFilterOperand.IS,
'2024-03-20',
'DATE',
);
expect(buildValueFromFilter({ filter })).toBe('2024-03-20');
});
it('returns the previous day for IS_BEFORE', () => {
const filter = createTestFilter(
ViewFilterOperand.IS_BEFORE,
'2024-03-20',
'DATE',
);
expect(buildValueFromFilter({ filter })).toBe('2024-03-19');
});
it('resolves IS_TODAY as today in the user time zone (day boundary)', () => {
jest.setSystemTime(new Date('2024-03-20T23:30:00Z'));
const filter = createTestFilter(ViewFilterOperand.IS_TODAY, '', 'DATE');
expect(buildValueFromFilter({ filter, timeZone: 'UTC' })).toBe(
'2024-03-20',
);
expect(buildValueFromFilter({ filter, timeZone: 'Asia/Tokyo' })).toBe(
'2024-03-21',
);
jest.setSystemTime(mockDate);
});
});
describe('NUMBER field type', () => {
const testCases = [
{
@@ -1,15 +1,9 @@
import { subMinutes } from 'date-fns';
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter';
import {
FieldMetadataType,
ViewFilterOperand,
type ObjectRecord,
} from 'twenty-shared/types';
import { type ObjectRecord } from 'twenty-shared/types';
import { deepMerge, isDefined } from 'twenty-shared/utils';
export const buildRecordInputFromFilter = ({
@@ -17,11 +11,13 @@ export const buildRecordInputFromFilter = ({
objectMetadataItem,
currentWorkspaceMember,
currentRecordId,
timeZone,
}: {
currentRecordFilters: RecordFilter[];
objectMetadataItem: EnrichedObjectMetadataItem;
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
timeZone: string;
}): Partial<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
@@ -42,6 +38,7 @@ export const buildRecordInputFromFilter = ({
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
currentRecordId,
label: filter.label,
timeZone,
});
if (!isDefined(value)) {
@@ -53,6 +50,7 @@ export const buildRecordInputFromFilter = ({
const value = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
timeZone,
});
if (!isDefined(value)) {
@@ -64,11 +62,6 @@ export const buildRecordInputFromFilter = ({
recordInput[fieldMetadataItem.name] ?? {},
value,
);
} else if (
fieldMetadataItem.type === FieldMetadataType.DATE_TIME &&
filter.operand === ViewFilterOperand.IS_BEFORE
) {
recordInput[fieldMetadataItem.name] = subMinutes(value as Date, 1);
} else {
recordInput[fieldMetadataItem.name] = value;
}
@@ -1,3 +1,5 @@
import { Temporal } from 'temporal-polyfill';
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
@@ -17,6 +19,8 @@ import {
assertUnreachable,
FILTER_OPERANDS_MAP,
parseJson,
parseToInstantOrThrow,
parseToPlainDateOrThrow,
} from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
@@ -37,6 +41,7 @@ type ValueComputeContext = {
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
label?: string;
timeZone?: string;
};
type ValueHandler = (context: ValueComputeContext) => unknown;
@@ -87,22 +92,40 @@ const computeValueFromContainsOperand = (
}
};
// TODO: fix this with Temporal
const filterDateTimeToInstant = (
value: string,
timeZone: string,
): Temporal.Instant =>
value.includes('T')
? parseToInstantOrThrow(value)
: parseToPlainDateOrThrow(value).toZonedDateTime(timeZone).toInstant();
const computeValueFromFilterDate = (
operand: RecordFilterToRecordInputOperand<'DATE_TIME'>,
value: string,
) => {
isDateOnly: boolean,
timeZone = 'UTC',
): string | undefined => {
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_AFTER:
return isDateOnly
? parseToPlainDateOrThrow(value).toString()
: filterDateTimeToInstant(value, timeZone).toString();
case ViewFilterOperand.IS_BEFORE:
return new Date(value);
return isDateOnly
? parseToPlainDateOrThrow(value).subtract({ days: 1 }).toString()
: filterDateTimeToInstant(value, timeZone)
.subtract({ minutes: 1 })
.toString();
case ViewFilterOperand.IS_TODAY:
case ViewFilterOperand.IS_NOT_EMPTY:
case ViewFilterOperand.IS_IN_PAST:
case ViewFilterOperand.IS_IN_FUTURE:
case ViewFilterOperand.IS_RELATIVE:
return new Date();
return isDateOnly
? Temporal.Now.plainDateISO(timeZone).toString()
: Temporal.Now.instant().toString();
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
@@ -300,15 +323,19 @@ const VALUE_HANDLER_REGISTRY: Partial<Record<FieldMetadataType, ValueHandler>> =
computeValueFromContainsOperand(operand as ContainsBasedOperand, value),
[FieldMetadataType.RAW_JSON]: ({ operand, value }) =>
computeValueFromContainsOperand(operand as ContainsBasedOperand, value),
[FieldMetadataType.DATE_TIME]: ({ operand, value }) =>
[FieldMetadataType.DATE_TIME]: ({ operand, value, timeZone }) =>
computeValueFromFilterDate(
operand as RecordFilterToRecordInputOperand<'DATE_TIME'>,
value,
false,
timeZone,
),
[FieldMetadataType.DATE]: ({ operand, value }) =>
[FieldMetadataType.DATE]: ({ operand, value, timeZone }) =>
computeValueFromFilterDate(
operand as RecordFilterToRecordInputOperand<'DATE_TIME'>,
value,
true,
timeZone,
),
[FieldMetadataType.NUMBER]: ({ operand, value }) =>
computeValueFromFilterNumber(
@@ -514,6 +541,7 @@ export const buildValueFromFilter = ({
currentWorkspaceMember,
currentRecordId,
label,
timeZone,
}: {
filter: RecordFilter;
options?: FilterOption[] | null;
@@ -521,6 +549,7 @@ export const buildValueFromFilter = ({
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
label?: string;
timeZone?: string;
}) => {
if (isCompositeFieldType(filter.type)) {
return buildCompositeValueFromFilter({
@@ -553,5 +582,6 @@ export const buildValueFromFilter = ({
currentWorkspaceMember,
currentRecordId,
label,
timeZone,
});
};