From d8cb4aa15b9d6ec0158360cbeaebfaa00809384d Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 25 Jun 2026 14:51:42 +0200 Subject: [PATCH] fix(filter): resolve filter-derived date defaults with Temporal (fixes create/update crash in date-filtered views) (#22124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 → → 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. --- .../useBuildRecordInputFromRLSPredicates.ts | 4 + .../hooks/useBuildRecordInputFromFilters.ts | 4 + .../buildRecordInputFromFilter.test.ts | 38 +++++++- .../__tests__/buildValueFromFilter.spec.ts | 88 +++++++++++++++---- .../utils/buildRecordInputFromFilter.ts | 17 ++-- .../utils/buildValueFromFilter.ts | 42 +++++++-- 6 files changed, 158 insertions(+), 35 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts b/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts index 477f8ff541..7c4defdec6 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts @@ -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 = { diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts index d0bc38032d..bc50630dc2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts @@ -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 => { return buildRecordInputFromFilter({ currentRecordFilters, objectMetadataItem, currentWorkspaceMember: currentWorkspaceMember ?? undefined, currentRecordId, + timeZone: userTimezone, }); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildRecordInputFromFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildRecordInputFromFilter.test.ts index c4917c2ffb..6e300c1956 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildRecordInputFromFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildRecordInputFromFilter.test.ts @@ -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 }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildValueFromFilter.spec.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildValueFromFilter.spec.ts index 432e31c16b..b956ac66d1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildValueFromFilter.spec.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/__tests__/buildValueFromFilter.spec.ts @@ -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 = [ { diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts index 269192877b..ef8b81b997 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts @@ -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 => { const recordInput: Partial = {}; @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts index 1327ef2a2f..7de3a1c74d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts @@ -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> = 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, }); };