Fix date type update (#9700)

This PR fixes a problem with how TypeORM handles date without time. 

A date without time that is stored in PostgreSQL database as `date` type
gets returned as an ISO string date with a timezone that can shift its
date part in an unwanted way.

In short DB stores `2025-01-01`, TypeORM query builder returns
`2024-12-31T23:00:00Z` which gets parsed as `2024-12-31` on the front
end field.

We don't want to handle timezone here because we are manipulating a date
without its time part, so this PR adds a step that counteracts what
TypeORM does and returns `2025-01-01T00:00:00.000Z` so that the front
can parse it correctly.

@Weiko We might want to check other places of the backend where date
types are returned by TypeORM, we might have the same problem, this PR
only fixes it for updateOne resolver return.

- Fixed date persist on frontend which was shifting the date to a
different day due to timezone issue
- Fixed date returned by the backend update logic, which was shifting
the date by the timezone offset (so this PR adds back the offset so that
it stays at 00:00:00Z time)
This commit is contained in:
Lucas Bordeau
2025-01-17 16:19:49 +01:00
committed by GitHub
parent b6b5fd1759
commit 6bd0244045
9 changed files with 97 additions and 33 deletions

View File

@ -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 (
<InternalDatePicker
<DateTimePicker
relativeDate={relativeDate}
highlightedDateRange={relativeDate}
isRelative={isRelativeOperand}
date={internalDate}
onChange={handleAbsoluteDateChange}
onRelativeDateChange={handleRelativeDateChange}
onMouseSelect={handleAbsoluteDateChange}
onClose={handleAbsoluteDateChange}
isDateTimeInput={isDateTimeInput}
/>
);

View File

@ -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 = ({
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<InternalDatePicker
<DateTimePicker
date={pickerDate ?? new Date()}
isDateTimeInput={false}
onChange={handlePickerChange}
onMouseSelect={handlePickerMouseSelect}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}

View File

@ -32,9 +32,13 @@ export const DateFieldInput = ({
if (!isDefined(newDate)) {
persistField(null);
} else {
const newDateISO = newDate?.toISOString();
const newDateWithoutTime = `${newDate?.getFullYear()}-${(
newDate?.getMonth() + 1
)
.toString()
.padStart(2, '0')}-${newDate?.getDate().toString().padStart(2, '0')}`;
persistField(newDateISO);
persistField(newDateWithoutTime);
}
};

View File

@ -3,7 +3,7 @@ import { Nullable } from 'twenty-ui';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import {
InternalDatePicker,
DateTimePicker,
MONTH_AND_YEAR_DROPDOWN_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
@ -54,7 +54,7 @@ export const DateInput = ({
onClear?.();
};
const handleMouseSelect = (newDate: Date | null) => {
const handleClose = (newDate: Date | null) => {
setInternalValue(newDate);
onSubmit?.(newDate);
};
@ -104,16 +104,16 @@ export const DateInput = ({
return (
<div ref={wrapperRef}>
<InternalDatePicker
<DateTimePicker
date={internalValue ?? new Date()}
onChange={handleChange}
onMouseSelect={handleMouseSelect}
onClose={handleClose}
clearable={clearable ? clearable : false}
isDateTimeInput={isDateTimeInput}
onEnter={onEnter}
onEscape={onEscape}
onClear={handleClear}
hideHeaderInput={hideHeaderInput}
isDateTimeInput={isDateTimeInput}
/>
</div>
);

View File

@ -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)

View File

@ -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<typeof InternalDatePicker> = {
const meta: Meta<typeof DateTimePicker> = {
title: 'UI/Input/Internal/InternalDatePicker',
component: InternalDatePicker,
component: DateTimePicker,
decorators: [ComponentDecorator],
argTypes: {
date: { control: 'date' },
@ -17,7 +17,7 @@ const meta: Meta<typeof InternalDatePicker> = {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [, updateArgs] = useArgs();
return (
<InternalDatePicker
<DateTimePicker
date={isDefined(date) ? new Date(date) : new Date()}
onChange={(newDate) => updateArgs({ date: newDate })}
/>
@ -27,7 +27,7 @@ const meta: Meta<typeof InternalDatePicker> = {
};
export default meta;
type Story = StoryObj<typeof InternalDatePicker>;
type Story = StoryObj<typeof DateTimePicker>;
export const Default: Story = {};

View File

@ -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<T>(
data: any,
ObjectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
objectMetadataMaps: ObjectMetadataMaps,
): T {
if (!data) {
@ -24,7 +28,7 @@ export function formatResult<T>(
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<T>(
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<T>(
);
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<T>(
);
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<T>(
if (isPlainObject(value)) {
newData[key] = formatResult(
value,
ObjectMetadataItemWithFieldMaps,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
} else if (objectMetadaItemFieldsByName[key]) {
@ -142,6 +146,56 @@ export function formatResult<T>(
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;
}

View File

@ -0,0 +1,3 @@
export const isDate = (date: any): date is Date => {
return date instanceof Date;
};

View File

@ -0,0 +1,3 @@
export const isValidDate = (date: any): date is Date => {
return date instanceof Date && !isNaN(date.getTime());
};