diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx index 5ddf33f6c..f23244514 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -5,7 +5,7 @@ import { selectedFilterComponentState } from '@/object-record/object-filter-drop import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter'; -import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; +import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; @@ -104,14 +104,14 @@ export const ObjectFilterDropdownDateInput = () => { : undefined; return ( - ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx index bb80b458f..9fd25f7f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx @@ -5,7 +5,7 @@ import { VariableChip } from '@/object-record/record-field/form-types/components import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { - InternalDatePicker, + DateTimePicker, MONTH_AND_YEAR_DROPDOWN_ID, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, @@ -351,11 +351,11 @@ export const FormDateTimeFieldInput = ({ - { + const handleClose = (newDate: Date | null) => { setInternalValue(newDate); onSubmit?.(newDate); }; @@ -104,16 +104,16 @@ export const DateInput = ({ return (
-
); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index 69876727b..d54d8a976 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -270,7 +270,7 @@ const StyledButton = styled(MenuItemLeftContent)` justify-content: start; `; -type InternalDatePickerProps = { +type DateTimePickerProps = { isRelative?: boolean; hideHeaderInput?: boolean; date: Date | null; @@ -283,7 +283,7 @@ type InternalDatePickerProps = { start: Date; end: Date; }; - onMouseSelect?: (date: Date | null) => void; + onClose?: (date: Date | null) => void; onChange?: (date: Date | null) => void; onRelativeDateChange?: ( relativeDate: { @@ -300,10 +300,10 @@ type InternalDatePickerProps = { onClear?: () => void; }; -export const InternalDatePicker = ({ +export const DateTimePicker = ({ date, onChange, - onMouseSelect, + onClose, clearable = true, isDateTimeInput, onClear, @@ -312,7 +312,7 @@ export const InternalDatePicker = ({ onRelativeDateChange, highlightedDateRange, hideHeaderInput, -}: InternalDatePickerProps) => { +}: DateTimePickerProps) => { const internalDate = date ?? new Date(); const { timeZone } = useContext(UserContext); @@ -336,9 +336,9 @@ export const InternalDatePicker = ({ closeDropdown(); }; - const handleMouseSelect = (newDate: Date) => { + const handleClose = (newDate: Date) => { closeDropdowns(); - onMouseSelect?.(newDate); + onClose?.(newDate); }; const handleChangeMonth = (month: number) => { @@ -396,7 +396,7 @@ export const InternalDatePicker = ({ }) .toJSDate(); - handleMouseSelect?.(dateParsed); + handleClose?.(dateParsed); }; const dateWithoutTime = DateTime.fromJSDate(internalDate) diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx index 74d54be0f..3bed37543 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx @@ -4,11 +4,11 @@ import { expect, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; -import { InternalDatePicker } from '../InternalDatePicker'; +import { DateTimePicker } from '../InternalDatePicker'; -const meta: Meta = { +const meta: Meta = { title: 'UI/Input/Internal/InternalDatePicker', - component: InternalDatePicker, + component: DateTimePicker, decorators: [ComponentDecorator], argTypes: { date: { control: 'date' }, @@ -17,7 +17,7 @@ const meta: Meta = { // eslint-disable-next-line react-hooks/rules-of-hooks const [, updateArgs] = useArgs(); return ( - updateArgs({ date: newDate })} /> @@ -27,7 +27,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index 954712fdf..956880cff 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -1,5 +1,7 @@ import { isPlainObject } from '@nestjs/common/utils/shared.utils'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; import { FieldMetadataType } from 'twenty-shared'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -12,10 +14,12 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met import { computeRelationType } from 'src/engine/twenty-orm/utils/compute-relation-type.util'; import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection'; import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; +import { isDate } from 'src/utils/date/isDate'; +import { isValidDate } from 'src/utils/date/isValidDate'; export function formatResult( data: any, - ObjectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, objectMetadataMaps: ObjectMetadataMaps, ): T { if (!data) { @@ -24,7 +28,7 @@ export function formatResult( if (Array.isArray(data)) { return data.map((item) => - formatResult(item, ObjectMetadataItemWithFieldMaps, objectMetadataMaps), + formatResult(item, objectMetadataItemWithFieldMaps, objectMetadataMaps), ) as T; } @@ -32,12 +36,12 @@ export function formatResult( return data; } - if (!ObjectMetadataItemWithFieldMaps) { + if (!objectMetadataItemWithFieldMaps) { throw new Error('Object metadata is missing'); } const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection( - ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps, ); const compositeFieldMetadataMap = new Map( @@ -58,7 +62,7 @@ export function formatResult( ); const relationMetadataMap = new Map( - Object.values(ObjectMetadataItemWithFieldMaps.fieldsById) + Object.values(objectMetadataItemWithFieldMaps.fieldsById) .filter(({ type }) => isRelationFieldMetadataType(type)) .map((fieldMetadata) => [ fieldMetadata.name, @@ -76,7 +80,7 @@ export function formatResult( ); const newData: object = {}; const objectMetadaItemFieldsByName = - objectMetadataMaps.byId[ObjectMetadataItemWithFieldMaps.id]?.fieldsByName; + objectMetadataMaps.byId[objectMetadataItemWithFieldMaps.id]?.fieldsByName; for (const [key, value] of Object.entries(data)) { const compositePropertyArgs = compositeFieldMetadataMap.get(key); @@ -87,7 +91,7 @@ export function formatResult( if (isPlainObject(value)) { newData[key] = formatResult( value, - ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps, objectMetadataMaps, ); } else if (objectMetadaItemFieldsByName[key]) { @@ -142,6 +146,56 @@ export function formatResult( newData[parentField][compositeProperty.name] = value; } + const dateFieldMetadataCollection = + objectMetadataItemWithFieldMaps.fields.filter( + (field) => field.type === FieldMetadataType.DATE, + ); + + // This is a temporary fix to handle a bug in the frontend where the date gets returned in the wrong timezone, + // thus returning the wrong date. + // + // In short, for example : + // - DB stores `2025-01-01` + // - TypeORM .returning() returns `2024-12-31T23:00:00.000Z` + // - we shift +1h (or whatever the timezone offset is on the server) + // - we return `2025-01-01T00:00:00.000Z` + // + // See this PR for more details: https://github.com/twentyhq/twenty/pull/9700 + const serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift = + new Date().getTimezoneOffset() * 60 * 1000; + + for (const dateFieldMetadata of dateFieldMetadataCollection) { + const rawUpdatedDate = newData[dateFieldMetadata.name] as + | string + | null + | undefined + | Date; + + if (!isDefined(rawUpdatedDate)) { + continue; + } + + if (isDate(rawUpdatedDate)) { + if (isValidDate(rawUpdatedDate)) { + const shiftedDate = new Date( + rawUpdatedDate.getTime() - + serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift, + ); + + newData[dateFieldMetadata.name] = shiftedDate; + } + } else if (isNonEmptyString(rawUpdatedDate)) { + const currentDate = new Date(newData[dateFieldMetadata.name]); + + const shiftedDate = new Date( + new Date(currentDate).getTime() - + serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift, + ); + + newData[dateFieldMetadata.name] = shiftedDate; + } + } + return newData as T; } diff --git a/packages/twenty-server/src/utils/date/isDate.ts b/packages/twenty-server/src/utils/date/isDate.ts new file mode 100644 index 000000000..8c2b80f47 --- /dev/null +++ b/packages/twenty-server/src/utils/date/isDate.ts @@ -0,0 +1,3 @@ +export const isDate = (date: any): date is Date => { + return date instanceof Date; +}; diff --git a/packages/twenty-server/src/utils/date/isValidDate.ts b/packages/twenty-server/src/utils/date/isValidDate.ts new file mode 100644 index 000000000..9f1775243 --- /dev/null +++ b/packages/twenty-server/src/utils/date/isValidDate.ts @@ -0,0 +1,3 @@ +export const isValidDate = (date: any): date is Date => { + return date instanceof Date && !isNaN(date.getTime()); +};