diff --git a/package.json b/package.json index 05b321da02e..39819f30d00 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "private": true, "dependencies": { "@apollo/client": "^3.7.17", + "@date-fns/tz": "^1.4.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.24.3", diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 20ffe6d004c..004ee0b1855 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -14,7 +14,6 @@ export type Scalars = { Int: number; Float: number; ConnectionCursor: any; - Date: any; DateTime: string; JSON: any; JSONObject: any; @@ -1042,15 +1041,15 @@ export type DatabaseEventTriggerIdInput = { id: Scalars['String']; }; -export type DateFilter = { - eq?: InputMaybe; - gt?: InputMaybe; - gte?: InputMaybe; - in?: InputMaybe>; +export type DateTimeFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; is?: InputMaybe; - lt?: InputMaybe; - lte?: InputMaybe; - neq?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; }; export type DeleteApprovedAccessDomainInput = { @@ -2912,12 +2911,12 @@ export type ObjectPermissionInput = { export type ObjectRecordFilterInput = { and?: InputMaybe>; - createdAt?: InputMaybe; - deletedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; id?: InputMaybe; not?: InputMaybe; or?: InputMaybe>; - updatedAt?: InputMaybe; + updatedAt?: InputMaybe; }; export type ObjectStandardOverrides = { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index e61bfe1c0e9..166dfe818dc 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -14,7 +14,6 @@ export type Scalars = { Int: number; Float: number; ConnectionCursor: any; - Date: any; DateTime: string; JSON: any; JSONObject: any; @@ -1006,15 +1005,15 @@ export type DatabaseEventTriggerIdInput = { id: Scalars['String']; }; -export type DateFilter = { - eq?: InputMaybe; - gt?: InputMaybe; - gte?: InputMaybe; - in?: InputMaybe>; +export type DateTimeFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; is?: InputMaybe; - lt?: InputMaybe; - lte?: InputMaybe; - neq?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; }; export type DeleteApprovedAccessDomainInput = { @@ -2823,12 +2822,12 @@ export type ObjectPermissionInput = { export type ObjectRecordFilterInput = { and?: InputMaybe>; - createdAt?: InputMaybe; - deletedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; id?: InputMaybe; not?: InputMaybe; or?: InputMaybe>; - updatedAt?: InputMaybe; + updatedAt?: InputMaybe; }; export type ObjectStandardOverrides = { diff --git a/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts b/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts index 1f08f690b5b..88deb53e850 100644 --- a/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts +++ b/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts @@ -1,10 +1,16 @@ import { type CalendarStartDay } from '@/localization/constants/CalendarStartDay'; +import { type ExcludeLiteral } from '~/types/ExcludeLiteral'; const MONDAY_KEY: keyof typeof CalendarStartDay = 'MONDAY'; const SATURDAY_KEY: keyof typeof CalendarStartDay = 'SATURDAY'; const SUNDAY_KEY: keyof typeof CalendarStartDay = 'SUNDAY'; -export const detectCalendarStartDay = (): keyof typeof CalendarStartDay => { +export type NonSystemCalendarStartDay = ExcludeLiteral< + keyof typeof CalendarStartDay, + 'SYSTEM' +>; + +export const detectCalendarStartDay = (): NonSystemCalendarStartDay => { // Use Intl.Locale to get the first day of the week from the user's locale // This requires a modern browser that supports Intl.Locale try { diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts index 765bb55813e..c812e12f287 100644 --- a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts @@ -15,6 +15,8 @@ export const formatDateISOStringToDateTime = ({ timeFormat: TimeFormat; localeCatalog: Locale; }) => { + // TODO: replace this with shiftPointInTimeToFromTimezoneDifference to remove date-fns-tz, which formatInTimeZone is doig under the hood : + // https://github.com/marnusw/date-fns-tz/blob/4f3383b26a5907a73b14512a2701f3dfd8cf1579/src/toZonedTime/index.ts#L36C9-L36C27 return formatInTimeZone( new Date(date), timeZone, diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx index e2605500dc6..73db3ee0042 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx @@ -10,8 +10,8 @@ import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter import { ObjectFilterDropdownCountrySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCountrySelect'; import { ObjectFilterDropdownCurrencySelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect'; import { ObjectFilterDropdownDateInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput'; +import { ObjectFilterDropdownDateTimeInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput'; import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput'; -import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; @@ -49,9 +49,8 @@ export const AdvancedFilterDropdownFilterInput = ({ ))} {filterType === 'RATING' && } - {DATE_FILTER_TYPES.includes(filterType) && ( - - )} + {filterType === 'DATE_TIME' && } + {filterType === 'DATE' && } {filterType === 'RELATION' && ( diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts index 49e586802e0..2e0bfd96fb2 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts @@ -1,10 +1,10 @@ import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById'; +import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue'; import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; -import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; @@ -56,6 +56,7 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => { ); const { upsertRecordFilter } = useUpsertRecordFilter(); + const { getInitialFilterValue } = useGetInitialFilterValue(); const selectFieldUsedInAdvancedFilterDropdown = ({ fieldMetadataItemId, 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 8cede443bdb..269e02009f0 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 @@ -1,29 +1,41 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CalendarStartDay } from '@/localization/constants/CalendarStartDay'; +import { + detectCalendarStartDay, + type NonSystemCalendarStartDay, +} from '@/localization/utils/detection/detectCalendarStartDay'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; -import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; +import { useGetNowInUserTimezoneForRelativeFilter } from '@/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; -import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; +import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; -import { useState } from 'react'; +import { UserContext } from '@/users/contexts/UserContext'; +import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ViewFilterOperand } from 'twenty-shared/types'; import { - ViewFilterOperand, - type VariableDateViewFilterValueDirection, - type VariableDateViewFilterValueUnit, -} from 'twenty-shared/types'; -import { isDefined, resolveDateViewFilterValue } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; + isDefined, + type RelativeDateFilter, + resolveDateFilter, +} from 'twenty-shared/utils'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; +import { formatDateString } from '~/utils/string/formatDateString'; export const ObjectFilterDropdownDateInput = () => { - const fieldMetadataItemUsedInDropdown = useRecoilComponentValue( - fieldMetadataItemUsedInDropdownComponentSelector, - ); + const { dateFormat, timeZone } = useContext(UserContext); + const dateLocale = useRecoilValue(dateLocaleState); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const selectedOperandInDropdown = useRecoilComponentValue( selectedOperandInDropdownComponentState, ); + const { getNowInUserTimezoneForRelativeFilter } = + useGetNowInUserTimezoneForRelativeFilter(); + const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue( objectFilterDropdownCurrentRecordFilterComponentState, ); @@ -31,43 +43,46 @@ export const ObjectFilterDropdownDateInput = () => { const { applyObjectFilterDropdownFilterValue } = useApplyObjectFilterDropdownFilterValue(); - const initialFilterValue = isDefined(objectFilterDropdownCurrentRecordFilter) - ? resolveDateViewFilterValue(objectFilterDropdownCurrentRecordFilter) - : null; + const handleAbsoluteDateChange = (newPlainDate: string | null) => { + const newFilterValue = newPlainDate ?? ''; - const [internalDate, setInternalDate] = useState( - initialFilterValue instanceof Date ? initialFilterValue : null, - ); + const formattedDate = formatDateString({ + value: newPlainDate, + timeZone, + dateFormat, + localeCatalog: dateLocale.localeCatalog, + }); - const isDateTimeInput = - fieldMetadataItemUsedInDropdown?.type === FieldMetadataType.DATE_TIME; - - const handleAbsoluteDateChange = (newDate: Date | null) => { - setInternalDate(newDate); - - const newFilterValue = newDate?.toISOString() ?? ''; - const newDisplayValue = isDefined(newDate) - ? isDateTimeInput - ? newDate.toLocaleString() - : newDate.toLocaleDateString() - : ''; + const newDisplayValue = isDefined(newPlainDate) ? formattedDate : ''; applyObjectFilterDropdownFilterValue(newFilterValue, newDisplayValue); }; const handleRelativeDateChange = ( - relativeDate: { - direction: VariableDateViewFilterValueDirection; - amount?: number; - unit: VariableDateViewFilterValueUnit; - } | null, + relativeDate: RelativeDateFilter | null, ) => { + const { dayAsStringInUserTimezone } = + getNowInUserTimezoneForRelativeFilter(); + + const userDefinedCalendarStartDay = + CalendarStartDay[ + currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM + ]; + const defaultSystemCalendarStartDay = detectCalendarStartDay(); + + const resolvedCalendarStartDay = ( + userDefinedCalendarStartDay === CalendarStartDay[CalendarStartDay.SYSTEM] + ? defaultSystemCalendarStartDay + : userDefinedCalendarStartDay + ) as NonSystemCalendarStartDay; + const newFilterValue = relativeDate - ? computeVariableDateViewFilterValue( - relativeDate.direction, - relativeDate.amount, - relativeDate.unit, - ) + ? stringifyRelativeDateFilter({ + ...relativeDate, + timezone: timeZone, + referenceDayAsString: dayAsStringInUserTimezone, + firstDayOfTheWeek: resolvedCalendarStartDay, + }) : ''; const newDisplayValue = relativeDate @@ -86,23 +101,26 @@ export const ObjectFilterDropdownDateInput = () => { : handleAbsoluteDateChange(null); }; const resolvedValue = objectFilterDropdownCurrentRecordFilter - ? resolveDateViewFilterValue(objectFilterDropdownCurrentRecordFilter) + ? resolveDateFilter(objectFilterDropdownCurrentRecordFilter) : null; const relativeDate = - resolvedValue && !(resolvedValue instanceof Date) + resolvedValue && typeof resolvedValue === 'object' + ? resolvedValue + : undefined; + + const plainDateValue = + resolvedValue && typeof resolvedValue === 'string' ? resolvedValue : undefined; return ( - ); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx new file mode 100644 index 00000000000..bb2b6342a5b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx @@ -0,0 +1,134 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CalendarStartDay } from '@/localization/constants/CalendarStartDay'; +import { + detectCalendarStartDay, + type NonSystemCalendarStartDay, +} from '@/localization/utils/detection/detectCalendarStartDay'; +import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; +import { useGetNowInUserTimezoneForRelativeFilter } from '@/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter'; +import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; +import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; +import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; +import { DateTimePicker } from '@/ui/input/components/internal/date/components/DateTimePicker'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { UserContext } from '@/users/contexts/UserContext'; +import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { useContext, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { ViewFilterOperand } from 'twenty-shared/types'; +import { + isDefined, + resolveDateTimeFilter, + type RelativeDateFilter, +} from 'twenty-shared/utils'; + +import { dateLocaleState } from '~/localization/states/dateLocaleState'; +import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; + +export const ObjectFilterDropdownDateTimeInput = () => { + const { dateFormat, timeFormat, timeZone } = useContext(UserContext); + const dateLocale = useRecoilValue(dateLocaleState); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const selectedOperandInDropdown = useRecoilComponentValue( + selectedOperandInDropdownComponentState, + ); + + const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue( + objectFilterDropdownCurrentRecordFilterComponentState, + ); + + const { applyObjectFilterDropdownFilterValue } = + useApplyObjectFilterDropdownFilterValue(); + + const initialFilterValue = isDefined(objectFilterDropdownCurrentRecordFilter) + ? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter) + : null; + + const { getNowInUserTimezoneForRelativeFilter } = + useGetNowInUserTimezoneForRelativeFilter(); + + const [internalDate, setInternalDate] = useState( + initialFilterValue instanceof Date ? initialFilterValue : null, + ); + + const handleAbsoluteDateChange = (newDate: Date | null) => { + setInternalDate(newDate); + + const newFilterValue = newDate?.toISOString() ?? ''; + + const formattedDateTime = formatDateTimeString({ + value: newDate?.toISOString(), + timeZone, + dateFormat, + timeFormat, + localeCatalog: dateLocale.localeCatalog, + }); + + const newDisplayValue = isDefined(newDate) ? formattedDateTime : ''; + + applyObjectFilterDropdownFilterValue(newFilterValue, newDisplayValue); + }; + + const handleRelativeDateChange = ( + relativeDate: RelativeDateFilter | null, + ) => { + const { dayAsStringInUserTimezone } = + getNowInUserTimezoneForRelativeFilter(); + + const userDefinedCalendarStartDay = + CalendarStartDay[ + currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM + ]; + const defaultSystemCalendarStartDay = detectCalendarStartDay(); + + const resolvedCalendarStartDay = ( + userDefinedCalendarStartDay === CalendarStartDay[CalendarStartDay.SYSTEM] + ? defaultSystemCalendarStartDay + : userDefinedCalendarStartDay + ) as NonSystemCalendarStartDay; + + const newFilterValue = relativeDate + ? stringifyRelativeDateFilter({ + ...relativeDate, + timezone: timeZone, + referenceDayAsString: dayAsStringInUserTimezone, + firstDayOfTheWeek: resolvedCalendarStartDay, + }) + : ''; + + const newDisplayValue = relativeDate + ? getRelativeDateDisplayValue(relativeDate) + : ''; + + applyObjectFilterDropdownFilterValue(newFilterValue, newDisplayValue); + }; + + const isRelativeOperand = + selectedOperandInDropdown === ViewFilterOperand.IS_RELATIVE; + + const handleClear = () => { + isRelativeOperand + ? handleRelativeDateChange(null) + : handleAbsoluteDateChange(null); + }; + const resolvedValue = objectFilterDropdownCurrentRecordFilter + ? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter) + : null; + + const relativeDate = + resolvedValue && !(resolvedValue instanceof Date) + ? resolvedValue + : undefined; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index 0581f10811e..e28743a3295 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -9,10 +9,10 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM import { ViewFilterOperand } from 'twenty-shared/types'; import { ObjectFilterDropdownBooleanSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownBooleanSelect'; +import { ObjectFilterDropdownDateTimeInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput'; import { ObjectFilterDropdownInnerSelectOperandDropdown } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown'; import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput'; import { ObjectFilterDropdownVectorSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownVectorSearchInput'; -import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes'; import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; @@ -69,7 +69,6 @@ export const ObjectFilterDropdownFilterInput = ({ fieldMetadataItemUsedInDropdown.type, ); - const isDateFilter = DATE_FILTER_TYPES.includes(filterType); const isOnlyOperand = !isOperandWithFilterValue; if (isOnlyOperand) { @@ -78,7 +77,7 @@ export const ObjectFilterDropdownFilterInput = ({ ); - } else if (isDateFilter) { + } else if (filterType === 'DATE') { return ( <> @@ -86,6 +85,14 @@ export const ObjectFilterDropdownFilterInput = ({ ); + } else if (filterType === 'DATE_TIME') { + return ( + <> + + + + + ); } else { return ( <> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts index 3f12e3ab61c..e65c992deab 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts @@ -1,4 +1,6 @@ import { DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW } from '@/object-record/object-filter-dropdown/constants/DateOperandsThatShouldBeInitializedWithNow'; +import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue'; +import { useGetNowInUserTimezoneForRelativeFilter } from '@/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter'; import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; @@ -7,15 +9,18 @@ import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropd import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; -import { getDateFilterDisplayValue } from '@/object-record/record-filter/utils/getDateFilterDisplayValue'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; + import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; -import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; +import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; + import { - type VariableDateViewFilterValueDirection, - type VariableDateViewFilterValueUnit, -} from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; + isDefined, + type RelativeDateFilter, + type RelativeDateFilterDirection, + type RelativeDateFilterUnit, +} from 'twenty-shared/utils'; export const useApplyObjectFilterDropdownOperand = () => { const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue( @@ -40,6 +45,13 @@ export const useApplyObjectFilterDropdownOperand = () => { const { createEmptyRecordFilterFromFieldMetadataItem } = useCreateEmptyRecordFilterFromFieldMetadataItem(); + const { getInitialFilterValue } = useGetInitialFilterValue(); + + const { userTimezone } = useUserTimezone(); + + const { getNowInUserTimezoneForRelativeFilter } = + useGetNowInUserTimezoneForRelativeFilter(); + const applyObjectFilterDropdownOperand = ( newOperand: RecordFilterOperand, ) => { @@ -84,27 +96,35 @@ export const useApplyObjectFilterDropdownOperand = () => { if ( DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW.includes(newOperand) ) { - const newDateValue = new Date(); + // TODO: allow to keep same value when switching between is after, is before, is and is not + // For now we reset with now each time we switch operand - recordFilterToUpsert.value = newDateValue.toISOString(); - const { displayValue } = getDateFilterDisplayValue( - newDateValue, + const dateToUseAsISOString = new Date().toISOString(); + + const { displayValue, value } = getInitialFilterValue( recordFilterToUpsert.type, + newOperand, + dateToUseAsISOString, ); + recordFilterToUpsert.value = value; + recordFilterToUpsert.displayValue = displayValue; } else if (newOperand === RecordFilterOperand.IS_RELATIVE) { - const defaultRelativeDate = { - direction: 'THIS' as VariableDateViewFilterValueDirection, + const { dayAsStringInUserTimezone } = + getNowInUserTimezoneForRelativeFilter(); + + const defaultRelativeDate: RelativeDateFilter = { + direction: 'THIS' as RelativeDateFilterDirection, amount: 1, - unit: 'DAY' as VariableDateViewFilterValueUnit, + unit: 'DAY' as RelativeDateFilterUnit, + timezone: userTimezone, + referenceDayAsString: dayAsStringInUserTimezone, }; - recordFilterToUpsert.value = computeVariableDateViewFilterValue( - defaultRelativeDate.direction, - defaultRelativeDate.amount, - defaultRelativeDate.unit, - ); + recordFilterToUpsert.value = + stringifyRelativeDateFilter(defaultRelativeDate); + recordFilterToUpsert.displayValue = getRelativeDateDisplayValue(defaultRelativeDate); } else { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts new file mode 100644 index 00000000000..3762090bd84 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts @@ -0,0 +1,49 @@ +import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat'; +import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat'; +import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat'; +import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { format } from 'date-fns'; +import { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from 'twenty-shared/utils'; + +export const useGetDateTimeFilterDisplayValue = () => { + const { + userTimezone, + isSystemTimezone, + getTimezoneAbbreviationForPointInTime, + } = useUserTimezone(); + const { userDateFormat } = useUserDateFormat(); + const { userTimeFormat } = useUserTimeFormat(); + + const getDateTimeFilterDisplayValue = (correctPointInTime: Date) => { + const shiftedDate = + shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone( + correctPointInTime, + userTimezone, + 'sub', + ); + + const dateFormatString = + getDateFormatFromWorkspaceDateFormat(userDateFormat); + + const timeFormatString = + getTimeFormatFromWorkspaceTimeFormat(userTimeFormat); + + const formatToUse = `${dateFormatString} ${timeFormatString}`; + + const timezoneSuffix = !isSystemTimezone + ? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})` + : ''; + + const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`; + + return { + correctPointInTime, + displayValue, + }; + }; + + return { + getDateTimeFilterDisplayValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts new file mode 100644 index 00000000000..d8b3eac585a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts @@ -0,0 +1,115 @@ +import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat'; +import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; +import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat'; +import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { TZDate } from '@date-fns/tz'; +import { isNonEmptyString } from '@sniptt/guards'; +import { format } from 'date-fns'; +import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; +import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; + +const activeDatePickerOperands = [ + RecordFilterOperand.IS_BEFORE, + RecordFilterOperand.IS, + RecordFilterOperand.IS_AFTER, +]; + +export const useGetInitialFilterValue = () => { + const { + userTimezone, + isSystemTimezone, + getTimezoneAbbreviationForPointInTime, + } = useUserTimezone(); + const { userDateFormat } = useUserDateFormat(); + const { userTimeFormat } = useUserTimeFormat(); + + const getInitialFilterValue = ( + newType: FilterableAndTSVectorFieldType, + newOperand: RecordFilterOperand, + alreadyExistingISODate?: string, + ): Pick | Record => { + switch (newType) { + case 'DATE': { + if (activeDatePickerOperands.includes(newOperand)) { + const referenceDate = isNonEmptyString(alreadyExistingISODate) + ? new Date(alreadyExistingISODate) + : new Date(); + + const shiftedDate = new TZDate( + referenceDate.getFullYear(), + referenceDate.getMonth(), + referenceDate.getDate(), + userTimezone, + ); + + const dateFormatString = + getDateFormatFromWorkspaceDateFormat(userDateFormat); + + shiftedDate.setSeconds(0); + shiftedDate.setMilliseconds(0); + + const value = format(shiftedDate, DATE_TYPE_FORMAT); + const displayValue = format(shiftedDate, dateFormatString); + + return { value, displayValue }; + } + + break; + } + case 'DATE_TIME': { + if (activeDatePickerOperands.includes(newOperand)) { + const referenceDate = isNonEmptyString(alreadyExistingISODate) + ? new Date(alreadyExistingISODate) + : new Date(); + + const shiftedDate = new TZDate( + referenceDate.getFullYear(), + referenceDate.getMonth(), + referenceDate.getDate(), + referenceDate.getHours(), + referenceDate.getMinutes(), + userTimezone, + ); + + shiftedDate.setSeconds(0); + shiftedDate.setMilliseconds(0); + + const dateFormatString = + getDateFormatFromWorkspaceDateFormat(userDateFormat); + + const timeFormatString = + getTimeFormatFromWorkspaceTimeFormat(userTimeFormat); + + const formatToUse = `${dateFormatString} ${timeFormatString}`; + + const timezoneSuffix = !isSystemTimezone + ? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})` + : ''; + + const value = shiftedDate.toISOString(); + const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`; + + return { value, displayValue }; + } + + if (newOperand === RecordFilterOperand.IS_RELATIVE) { + return { value: '', displayValue: '' }; + } + + break; + } + } + + return { + value: '', + displayValue: '', + }; + }; + + return { + getInitialFilterValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter.ts new file mode 100644 index 00000000000..473620b0ff0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter.ts @@ -0,0 +1,33 @@ +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { TZDate } from '@date-fns/tz'; +import { format } from 'date-fns'; +import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; + +export const useGetNowInUserTimezoneForRelativeFilter = () => { + const { userTimezone } = useUserTimezone(); + + const getNowInUserTimezoneForRelativeFilter = () => { + const now = new Date(); + + const nowInUserTimezone = new TZDate( + now.getFullYear(), + now.getMonth(), + now.getDate(), + userTimezone, + ); + + const dayAsStringInUserTimezone = format( + nowInUserTimezone, + DATE_TYPE_FORMAT, + ); + + return { + nowInUserTimezone, + dayAsStringInUserTimezone, + }; + }; + + return { + getNowInUserTimezoneForRelativeFilter, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts deleted file mode 100644 index 32f2f92d920..00000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; -import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; -import { getDateFilterDisplayValue } from '@/object-record/record-filter/utils/getDateFilterDisplayValue'; -import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; - -export const getInitialFilterValue = ( - newType: FilterableAndTSVectorFieldType, - newOperand: RecordFilterOperand, -): Pick | Record => { - switch (newType) { - case 'DATE': - case 'DATE_TIME': { - const activeDatePickerOperands = [ - RecordFilterOperand.IS_BEFORE, - RecordFilterOperand.IS, - RecordFilterOperand.IS_AFTER, - ]; - - if (activeDatePickerOperands.includes(newOperand)) { - const date = new Date(); - const value = date.toISOString(); - - const { displayValue } = getDateFilterDisplayValue(date, newType); - - return { value, displayValue }; - } - - if (newOperand === RecordFilterOperand.IS_RELATIVE) { - return { value: '', displayValue: '' }; - } - - break; - } - } - - return { - value: '', - displayValue: '', - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts index ca3c992b2c9..0ab46a8b7ec 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts @@ -1,16 +1,9 @@ import { plural } from 'pluralize'; -import { - type VariableDateViewFilterValueDirection, - type VariableDateViewFilterValueUnit, -} from 'twenty-shared/types'; -import { capitalize } from 'twenty-shared/utils'; + +import { capitalize, type RelativeDateFilter } from 'twenty-shared/utils'; export const getRelativeDateDisplayValue = ( - relativeDate: { - direction: VariableDateViewFilterValueDirection; - amount?: number; - unit: VariableDateViewFilterValueUnit; - } | null, + relativeDate: RelativeDateFilter | null, ) => { if (!relativeDate) return ''; const { direction, amount, unit } = relativeDate; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardCellEditModePortal.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardCellEditModePortal.tsx index d00fe91ff91..69161e565e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardCellEditModePortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardCellEditModePortal.tsx @@ -28,7 +28,6 @@ export const RecordBoardCardCellEditModePortal = () => { return ( { const { objectMetadataItem } = useContext(RecordBoardContext); const { recordId } = useContext(RecordBoardCardContext); - const hoverPosition = useRecoilComponentValue( - recordBoardCardHoverPositionComponentState, - ); - const { hoveredFieldMetadataItem } = useRecordBoardCardMetadataFromPosition(); - if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) { + if (!isDefined(hoveredFieldMetadataItem)) { return null; } return ( { { {weekFirstDays.map((weekFirstDay) => ( ))} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx index e05f28a5f09..f4832038038 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx @@ -8,6 +8,7 @@ import styled from '@emotion/styled'; import { Droppable } from '@hello-pangea/dnd'; import { format, isSameDay, isSameMonth, isWeekend } from 'date-fns'; import { useState } from 'react'; +import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; import { RecordCalendarAddNew } from '../../components/RecordCalendarAddNew'; const StyledContainer = styled.div<{ @@ -103,7 +104,7 @@ export const RecordCalendarMonthBodyDay = ({ recordCalendarSelectedDateComponentState, ); - const dayKey = format(day, 'yyyy-MM-dd'); + const dayKey = format(day, DATE_TYPE_FORMAT); const recordIds = useRecoilComponentFamilyValue( calendarDayRecordIdsComponentFamilySelector, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellEditModePortal.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellEditModePortal.tsx index 9463af2fb17..fe97cef244a 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellEditModePortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellEditModePortal.tsx @@ -32,7 +32,6 @@ export const RecordCalendarCardCellEditModePortal = ({ return ( theme.spacing(1)}; +`; + +const StyledDateInput = styled.input<{ hasError?: boolean }>` + ${TEXT_INPUT_STYLE} + + &:disabled { + color: ${({ theme }) => theme.font.color.tertiary}; + } + + ${({ hasError, theme }) => + hasError && + css` + color: ${theme.color.red}; + `}; +`; + +const StyledDateInputContainer = styled.div` + position: relative; + z-index: 1; +`; + +type DraftValue = + | { + type: 'static'; + value: string | null; + mode: 'view' | 'edit'; + } + | { + type: 'variable'; + value: string; + }; type FormDateFieldInputProps = { label?: string; defaultValue: string | undefined; onChange: (value: string | null) => void; + placeholder?: string; VariablePicker?: VariablePickerComponent; readonly?: boolean; - placeholder?: string; }; export const FormDateFieldInput = ({ @@ -18,15 +94,277 @@ export const FormDateFieldInput = ({ readonly, placeholder, }: FormDateFieldInputProps) => { + const instanceId = useId(); + const { dateFormat } = useDateTimeFormat(); + + const { parsePlainDateToDateInputString } = + useParsePlainDateToDateInputString(); + const { parseDateInputStringToPlainDate } = + useParseDateInputStringToPlainDate(); + + const [draftValue, setDraftValue] = useState( + isStandaloneVariableString(defaultValue) + ? { + type: 'variable', + value: defaultValue, + } + : { + type: 'static', + value: defaultValue ?? null, + mode: 'view', + }, + ); + + const draftValueAsDate = + isDefined(draftValue.value) && + isNonEmptyString(draftValue.value) && + draftValue.type === 'static' + ? draftValue.value + : null; + + const [pickerDate, setPickerDate] = + useState>(draftValueAsDate); + + const datePickerWrapperRef = useRef(null); + + const [inputDate, setInputDate] = useState( + isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue) + ? parsePlainDateToDateInputString(draftValueAsDate) + : '', + ); + + const persistDate = (newDate: Nullable) => { + if (!isDefined(newDate)) { + onChange(null); + } else { + onChange(newDate); + } + }; + + const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); + const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); + + const displayDatePicker = + draftValue.type === 'static' && draftValue.mode === 'edit'; + + const defaultPlaceHolder = + getDateFormatStringForDatePickerInputMask(dateFormat); + + const placeholderToDisplay = placeholder ?? defaultPlaceHolder; + + useListenClickOutside({ + refs: [datePickerWrapperRef], + listenerId: 'FormDateTimeFieldInputBase', + callback: (event) => { + event.stopImmediatePropagation(); + + closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + handlePickerClickOutside(); + }, + enabled: displayDatePicker, + excludedClickOutsideIds: [ + MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, + MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, + ], + }); + + const handlePickerChange = (newDate: Nullable) => { + setDraftValue({ + type: 'static', + mode: 'edit', + value: newDate ?? null, + }); + + setInputDate( + isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '', + ); + + setPickerDate(newDate); + + persistDate(newDate); + }; + + const handlePickerEnter = () => {}; + + const handlePickerEscape = () => { + // FIXME: Escape key is not handled properly by the underlying DateInput component. We need to solve that. + + setDraftValue({ + type: 'static', + value: draftValue.value, + mode: 'view', + }); + }; + + const handlePickerClickOutside = () => { + setDraftValue({ + type: 'static', + value: draftValue.value, + mode: 'view', + }); + }; + + const handlePickerClear = () => { + setDraftValue({ + type: 'static', + value: null, + mode: 'view', + }); + + setPickerDate(null); + + setInputDate(''); + + persistDate(null); + }; + + const handlePickerMouseSelect = (newDate: Nullable) => { + setDraftValue({ + type: 'static', + value: newDate ?? null, + mode: 'view', + }); + + setPickerDate(newDate); + + setInputDate( + isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '', + ); + + persistDate(newDate); + }; + + const handleInputFocus = () => { + setDraftValue({ + type: 'static', + mode: 'edit', + value: draftValue.value, + }); + }; + + const handleInputChange = (event: ChangeEvent) => { + setInputDate(event.target.value); + }; + + const handleInputKeydown = (event: KeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + + const inputDateTime = inputDate.trim(); + + if (inputDateTime === '') { + handlePickerClear(); + return; + } + + const parsedInputPlainDate = parseDateInputStringToPlainDate(inputDateTime); + + if (!isDefined(parsedInputPlainDate)) { + return; + } + + let validatedDate = parsedInputPlainDate; + + setDraftValue({ + type: 'static', + value: validatedDate, + mode: 'edit', + }); + + setPickerDate(validatedDate); + + setInputDate(parsePlainDateToDateInputString(validatedDate)); + + persistDate(validatedDate); + }; + + const handleVariableTagInsert = (variableName: string) => { + setDraftValue({ + type: 'variable', + value: variableName, + }); + + setInputDate(''); + + onChange(variableName); + }; + + const handleUnlinkVariable = () => { + setDraftValue({ + type: 'static', + value: null, + mode: 'view', + }); + + setPickerDate(null); + + onChange(null); + }; + + useHotkeysOnFocusedElement({ + keys: [Key.Escape], + callback: handlePickerEscape, + focusId: instanceId, + dependencies: [handlePickerEscape], + }); + return ( - + + {label ? {label} : null} + + + + {draftValue.type === 'static' ? ( + <> + + + {draftValue.mode === 'edit' ? ( + + + + + + + + ) : null} + + ) : ( + + )} + + + {VariablePicker && !readonly ? ( + + ) : null} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx index 5b03469c7cd..34a3d3d2252 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx @@ -8,10 +8,12 @@ import { DateTimePicker, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, -} from '@/ui/input/components/internal/date/components/InternalDatePicker'; +} from '@/ui/input/components/internal/date/components/DateTimePicker'; import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate'; import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'; -import { useDateParser } from '@/ui/input/components/internal/hooks/useDateParser'; +import { useParseDateTimeInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate'; +import { useParseJSDateToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString'; + import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; @@ -76,7 +78,6 @@ type DraftValue = }; type FormDateTimeFieldInputProps = { - dateOnly?: boolean; label?: string; defaultValue: string | undefined; onChange: (value: string | null) => void; @@ -86,7 +87,6 @@ type FormDateTimeFieldInputProps = { }; export const FormDateTimeFieldInput = ({ - dateOnly, label, defaultValue, onChange, @@ -94,12 +94,13 @@ export const FormDateTimeFieldInput = ({ readonly, placeholder, }: FormDateTimeFieldInputProps) => { - const { parseToString, parseToDate } = useDateParser({ - isDateTimeInput: !dateOnly, - }); - const instanceId = useId(); + const { parseJSDateToDateTimeInputString: parseDateTimeToString } = + useParseJSDateToIMaskDateTimeInputString(); + const { parseDateTimeInputStringToJSDate: parseStringToDateTime } = + useParseDateTimeInputStringToJSDate(); + const [draftValue, setDraftValue] = useState( isStandaloneVariableString(defaultValue) ? { @@ -127,7 +128,7 @@ export const FormDateTimeFieldInput = ({ const [inputDateTime, setInputDateTime] = useState( isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue) - ? parseToString(draftValueAsDate) + ? parseDateTimeToString(draftValueAsDate) : '', ); @@ -147,8 +148,7 @@ export const FormDateTimeFieldInput = ({ const displayDatePicker = draftValue.type === 'static' && draftValue.mode === 'edit'; - const placeholderToDisplay = - placeholder ?? (dateOnly ? 'mm/dd/yyyy' : 'mm/dd/yyyy hh:mm'); + const placeholderToDisplay = placeholder ?? 'mm/dd/yyyy hh:mm'; useListenClickOutside({ refs: [datePickerWrapperRef], @@ -174,7 +174,7 @@ export const FormDateTimeFieldInput = ({ value: newDate?.toDateString() ?? null, }); - setInputDateTime(isDefined(newDate) ? parseToString(newDate) : ''); + setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : ''); setPickerDate(newDate); @@ -224,7 +224,7 @@ export const FormDateTimeFieldInput = ({ setPickerDate(newDate); - setInputDateTime(isDefined(newDate) ? parseToString(newDate) : ''); + setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : ''); persistDate(newDate); }; @@ -253,7 +253,7 @@ export const FormDateTimeFieldInput = ({ return; } - const parsedInputDateTime = parseToDate(inputDateTimeTrimmed); + const parsedInputDateTime = parseStringToDateTime(inputDateTimeTrimmed); if (!isDefined(parsedInputDateTime)) { return; @@ -274,7 +274,7 @@ export const FormDateTimeFieldInput = ({ setPickerDate(validatedDate); - setInputDateTime(parseToString(validatedDate)); + setInputDateTime(parseDateTimeToString(validatedDate)); persistDate(validatedDate); }; @@ -337,7 +337,6 @@ export const FormDateTimeFieldInput = ({ { const value = isString(defaultValue) && isNonEmptyString(defaultValue) - ? safeParseRelativeDateFilterValue(defaultValue) - : DEFAULT_RELATIVE_DATE_VALUE; + ? safeParseRelativeDateFilterJSONStringified(defaultValue) + : DEFAULT_RELATIVE_DATE_FILTER_VALUE; - const handleValueChange = (newValue: VariableDateViewFilterValue) => { + const handleValueChange = (newValue: RelativeDateFilter) => { onChange(JSON.stringify(newValue)); }; @@ -34,7 +35,7 @@ export const FormRelativeDatePicker = ({ onChange={handleValueChange} direction={value?.direction ?? 'THIS'} unit={value?.unit ?? 'DAY'} - amount={value?.amount} + amount={value?.amount ?? undefined} isFormField={true} readonly={readonly} unitDropdownWidth={150} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx deleted file mode 100644 index 05d5ebe66b3..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx +++ /dev/null @@ -1,409 +0,0 @@ -import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate'; -import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'; -import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString'; -import { type Meta, type StoryObj } from '@storybook/react'; -import { - expect, - fn, - userEvent, - waitFor, - waitForElementToBeRemoved, - within, -} from '@storybook/test'; -import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; -import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; -import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow'; -import { FormDateFieldInput } from '../FormDateFieldInput'; - -const meta: Meta = { - title: 'UI/Data/Field/Form/Input/FormDateFieldInput', - component: FormDateFieldInput, - args: {}, - argTypes: {}, - decorators: [I18nFrontDecorator, WorkflowStepDecorator], -}; - -export default meta; - -type Story = StoryObj; - -const currentYear = new Date().getFullYear(); - -export const Default: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue('12/09/' + currentYear); - }, -}; - -export const WithDefaultEmptyValue: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue(''); - await canvas.findByPlaceholderText('mm/dd/yyyy'); - }, -}; - -export const SetsDateWithInput: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - - await userEvent.click(input); - - const dialog = await canvas.findByRole('dialog'); - expect(dialog).toBeVisible(); - - await userEvent.type(input, `12/08/${currentYear}{enter}`); - - await waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith( - `${currentYear}-12-08T00:00:00.000Z`, - ); - }); - - expect(dialog).toBeVisible(); - }, -}; - -export const SetsDateWithDatePicker: Story = { - args: { - label: 'Created At', - defaultValue: `2024-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const dayToChoose = await within(datePicker).findByRole('option', { - name: `Choose Saturday, December 7th, 2024`, - }); - - await Promise.all([ - userEvent.click(dayToChoose), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith( - expect.stringMatching(new RegExp(`^2024-12-07`)), - ); - }), - waitFor(() => { - expect(canvas.getByDisplayValue(`12/07/2024`)).toBeVisible(); - }), - ]); - }, -}; - -export const ResetsDateByClickingButton: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const clearButton = await canvas.findByText('Clear'); - - await Promise.all([ - userEvent.click(clearButton), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(null); - }), - waitFor(() => { - expect(input).toHaveDisplayValue(''); - }), - ]); - }, -}; - -export const ResetsDateByErasingInputContent: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - expect(input).toHaveDisplayValue(`12/09/${currentYear}`); - - await userEvent.clear(input); - - await userEvent.type(input, '{Enter}'); - - expect(args.onChange).toHaveBeenCalledWith(null); - expect(input).toHaveDisplayValue(''); - }, -}; - -export const DefaultsToMinValueWhenTypingReallyOldDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await userEvent.type(input, '02/02/1500{Enter}'); - await waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(MIN_DATE.toISOString()); - }); - - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MIN_DATE, - isDateTimeInput: false, - userTimezone: undefined, - }), - ); - - const expectedDate = new Date( - MIN_DATE.getUTCFullYear(), - MIN_DATE.getUTCMonth(), - MIN_DATE.getUTCDate(), - 0, - 0, - 0, - 0, - ); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Sunday, December 31st, 1899" - return accessibleName.includes(expectedDate.getFullYear().toString()); - }, - }); - expect(selectedDay).toBeVisible(); - }, -}; - -export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.type(input, '02/02/2500{Enter}'), - - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(MAX_DATE.toISOString()); - }), - waitFor(() => { - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MAX_DATE, - isDateTimeInput: false, - userTimezone: undefined, - }), - ); - }), - waitFor(() => { - const expectedDate = new Date( - MAX_DATE.getUTCFullYear(), - MAX_DATE.getUTCMonth(), - MAX_DATE.getUTCDate(), - 0, - 0, - 0, - 0, - ); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Thursday, December 30th, 2100" - return accessibleName.includes( - expectedDate.getFullYear().toString(), - ); - }, - }); - expect(selectedDay).toBeVisible(); - }), - ]); - }, -}; - -export const SwitchesToStandaloneVariable: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - VariablePicker: ({ onVariableSelect }) => { - return ( - - ); - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const addVariableButton = await canvas.findByText('Add variable'); - await userEvent.click(addVariableButton); - - const variableTag = await canvas.findByText('Creation date'); - expect(variableTag).toBeVisible(); - - const removeVariableButton = canvas.getByLabelText('Remove variable'); - - await Promise.all([ - userEvent.click(removeVariableButton), - - waitForElementToBeRemoved(variableTag), - waitFor(() => { - const input = canvas.getByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - }), - ]); - }, -}; - -export const ClickingOutsideDoesNotResetInputState: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - if (!args.defaultValue) { - throw new Error('This test requires a defaultValue'); - } - - const defaultValueAsDisplayString = parseDateToString({ - date: new Date(args.defaultValue), - isDateTimeInput: false, - userTimezone: undefined, - }); - - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - expect(input).toHaveDisplayValue(defaultValueAsDisplayString); - - await userEvent.type(input, '{Backspace}{Backspace}'); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.click(canvasElement), - - waitForElementToBeRemoved(datePicker), - ]); - - expect(args.onChange).not.toHaveBeenCalled(); - - expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2)); - }, -}; - -export const Disabled: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - readonly: true, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByDisplayValue('12/09/' + currentYear); - expect(input).toBeDisabled(); - }, -}; - -export const DisabledWithVariable: Story = { - args: { - label: 'Created At', - defaultValue: `{{${MOCKED_STEP_ID}.createdAt}}`, - onChange: fn(), - readonly: true, - VariablePicker: ({ onVariableSelect }) => { - return ( - - ); - }, - }, - decorators: [WorkflowStepDecorator], - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const variableChip = await canvas.findByText('Creation date'); - expect(variableChip).toBeVisible(); - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx deleted file mode 100644 index 8ab9d80a75a..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput'; -import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate'; -import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'; -import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString'; -import { type Meta, type StoryObj } from '@storybook/react'; -import { - expect, - fn, - userEvent, - waitFor, - waitForElementToBeRemoved, - within, -} from '@storybook/test'; -import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; -import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; -import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow'; - -const meta: Meta = { - title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput', - component: FormDateTimeFieldInput, - args: {}, - argTypes: {}, - decorators: [I18nFrontDecorator, WorkflowStepDecorator], -}; - -export default meta; - -type Story = StoryObj; - -const currentYear = new Date().getFullYear(); - -export const Default: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue( - new RegExp(`12/09/${currentYear} \\d{2}:20`), - ); - }, -}; - -export const WithDefaultEmptyValue: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue(''); - await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - }, -}; - -export const SetsDateTimeWithInput: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - - await userEvent.click(input); - - const dialog = await canvas.findByRole('dialog'); - expect(dialog).toBeVisible(); - - await userEvent.type(input, `12/08/${currentYear} 12:10{enter}`); - - await waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith( - expect.stringMatching(new RegExp(`^${currentYear}-12-08`)), - ); - }); - - expect(dialog).toBeVisible(); - }, -}; - -export const DoesNotSetDateWithoutTime: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - - await userEvent.click(input); - - const dialog = await canvas.findByRole('dialog'); - expect(dialog).toBeVisible(); - - await userEvent.type(input, `12/08/${currentYear}{enter}`); - - expect(args.onChange).not.toHaveBeenCalled(); - expect(dialog).toBeVisible(); - }, -}; - -export const SetsDateTimeWithDatePicker: Story = { - args: { - label: 'Created At', - defaultValue: `2024-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const dayToChoose = await within(datePicker).findByRole('option', { - name: 'Choose Saturday, December 7th, 2024', - }); - - await Promise.all([ - userEvent.click(dayToChoose), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith( - expect.stringMatching(/^2024-12-07/), - ); - }), - waitFor(() => { - expect( - canvas.getByDisplayValue(new RegExp(`12/07/2024 \\d{2}:\\d{2}`)), - ).toBeVisible(); - }), - ]); - }, -}; - -export const ResetsDateByClickingButton: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const clearButton = await canvas.findByText('Clear'); - - await Promise.all([ - userEvent.click(clearButton), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(null); - }), - waitFor(() => { - expect(input).toHaveDisplayValue(''); - }), - ]); - }, -}; - -export const ResetsDateByErasingInputContent: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - - expect(input).toHaveDisplayValue( - new RegExp(`12/09/${currentYear} \\d{2}:\\d{2}`), - ); - - await userEvent.click(input); - - await waitFor(() => { - expect(canvas.getByRole('dialog')).toBeVisible(); - }); - - await userEvent.clear(input); - - const waitForDialogToBeRemoved = waitForElementToBeRemoved(() => - canvas.queryByRole('dialog'), - ); - - await Promise.all([ - userEvent.type(input, '{Enter}'), - - waitForDialogToBeRemoved, - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(null); - }), - waitFor(() => { - expect(input).toHaveDisplayValue(''); - }), - ]); - }, -}; - -export const DefaultsToMinValueWhenTypingReallyOldDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.type(input, '02/02/1500 10:10{Enter}'), - - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(MIN_DATE.toISOString()); - }), - waitFor(() => { - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MIN_DATE, - isDateTimeInput: true, - userTimezone: undefined, - }), - ); - }), - waitFor(() => { - const expectedDate = new Date( - MIN_DATE.getUTCFullYear(), - MIN_DATE.getUTCMonth(), - MIN_DATE.getUTCDate(), - 0, - 0, - 0, - 0, - ); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Sunday, December 31st, 1899" - return accessibleName.includes( - expectedDate.getFullYear().toString(), - ); - }, - }); - expect(selectedDay).toBeVisible(); - }), - ]); - }, -}; - -export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.type(input, '02/02/2500 10:10{Enter}'), - - waitFor(() => { - expect(args.onChange).toHaveBeenCalledWith(MAX_DATE.toISOString()); - }), - waitFor(() => { - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MAX_DATE, - isDateTimeInput: true, - userTimezone: undefined, - }), - ); - }), - waitFor(() => { - const expectedDate = new Date( - MAX_DATE.getUTCFullYear(), - MAX_DATE.getUTCMonth(), - MAX_DATE.getUTCDate(), - 0, - 0, - 0, - 0, - ); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Thursday, December 30th, 2100" - return accessibleName.includes( - expectedDate.getFullYear().toString(), - ); - }, - }); - expect(selectedDay).toBeVisible(); - }), - ]); - }, -}; - -export const SwitchesToStandaloneVariable: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onChange: fn(), - VariablePicker: ({ onVariableSelect }) => { - return ( - - ); - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const addVariableButton = await canvas.findByText('Add variable'); - await userEvent.click(addVariableButton); - - const variableTag = await canvas.findByText('Creation date'); - expect(variableTag).toBeVisible(); - - const removeVariableButton = canvas.getByLabelText('Remove variable'); - - await Promise.all([ - userEvent.click(removeVariableButton), - - waitForElementToBeRemoved(variableTag), - waitFor(() => { - const input = canvas.getByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - }), - ]); - }, -}; - -export const ClickingOutsideDoesNotResetInputState: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - }, - play: async ({ canvasElement, args }) => { - if (!args.defaultValue) { - throw new Error('This test requires a defaultValue'); - } - - const defaultValueAsDisplayString = parseDateToString({ - date: new Date(args.defaultValue), - isDateTimeInput: true, - userTimezone: undefined, - }); - - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); - expect(input).toBeVisible(); - expect(input).toHaveDisplayValue(defaultValueAsDisplayString); - - await userEvent.type(input, '{Backspace}{Backspace}'); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.click(canvasElement), - - waitForElementToBeRemoved(datePicker), - ]); - - expect(args.onChange).not.toHaveBeenCalled(); - - expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2)); - }, -}; - -export const Disabled: Story = { - args: { - label: 'Created At', - defaultValue: `${currentYear}-12-09T13:20:19.631Z`, - onChange: fn(), - readonly: true, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByDisplayValue( - new RegExp(`12/09/${currentYear} \\d{2}:20`), - ); - expect(input).toBeDisabled(); - }, -}; - -export const DisabledWithVariable: Story = { - args: { - label: 'Created At', - defaultValue: `{{${MOCKED_STEP_ID}.createdAt}}`, - onChange: fn(), - readonly: true, - VariablePicker: ({ onVariableSelect }) => { - return ( - - ); - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const variableChip = await canvas.findByText('Creation date'); - expect(variableChip).toBeVisible(); - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx index d146ae6eb71..d0ec304ff36 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx @@ -50,7 +50,7 @@ export const Elipsis: Story = { export const Performance = getProfilingStory({ componentName: 'DateTimeFieldDisplay', - averageThresholdInMs: 0.1, + averageThresholdInMs: 0.15, numberOfRuns: 30, numberOfTestsPerRun: 30, }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/DateFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/DateFieldInput.tsx index 5822d12b364..82959f7c6e5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/DateFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/DateFieldInput.tsx @@ -5,7 +5,6 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useContext } from 'react'; -import { isDefined } from 'twenty-shared/utils'; import { type Nullable } from 'twenty-ui/utilities'; export const DateFieldInput = () => { @@ -19,48 +18,34 @@ export const DateFieldInput = () => { RecordFieldComponentInstanceContext, ); - const getDateToPersist = (newDate: Nullable) => { - if (!isDefined(newDate)) { - return null; - } else { - const newDateWithoutTime = `${newDate?.getFullYear()}-${( - newDate?.getMonth() + 1 - ) - .toString() - .padStart(2, '0')}-${newDate?.getDate().toString().padStart(2, '0')}`; - - return newDateWithoutTime; - } + const handleEnter = (newDate: Nullable) => { + onEnter?.({ newValue: newDate }); }; - const handleEnter = (newDate: Nullable) => { - onEnter?.({ newValue: getDateToPersist(newDate) }); + const handleSubmit = (newDate: Nullable) => { + onSubmit?.({ newValue: newDate }); }; - const handleSubmit = (newDate: Nullable) => { - onSubmit?.({ newValue: getDateToPersist(newDate) }); - }; - - const handleEscape = (newDate: Nullable) => { - onEscape?.({ newValue: getDateToPersist(newDate) }); + const handleEscape = (newDate: Nullable) => { + onEscape?.({ newValue: newDate }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, - newDate: Nullable, + newDate: Nullable, ) => { - onClickOutside?.({ newValue: getDateToPersist(newDate), event }); + onClickOutside?.({ newValue: newDate, event }); }; - const handleChange = (newDate: Nullable) => { - setDraftValue(newDate?.toDateString() ?? ''); + const handleChange = (newDate: Nullable) => { + setDraftValue(newDate ?? ''); }; const handleClear = () => { onSubmit?.({ newValue: null }); }; - const dateValue = fieldValue ? new Date(fieldValue) : null; + const dateValue = fieldValue ?? null; return ( { const dateValue = fieldValue ? new Date(fieldValue) : null; return ( - { value={dateValue} clearable onChange={handleChange} - isDateTimeInput onClear={handleClear} onSubmit={handleSubmit} /> diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem.ts index 20cb16ae056..a317ff5a6b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem.ts @@ -1,5 +1,5 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue'; +import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; @@ -7,6 +7,8 @@ import { getFilterTypeFromFieldType } from 'twenty-shared/utils'; import { v4 } from 'uuid'; export const useCreateEmptyRecordFilterFromFieldMetadataItem = () => { + const { getInitialFilterValue } = useGetInitialFilterValue(); + const createEmptyRecordFilterFromFieldMetadataItem = ( fieldMetadataItem: FieldMetadataItem, ) => { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts index 20cd12385f4..f7f14f0cca7 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts @@ -1108,12 +1108,12 @@ describe('should work as expected for the different field types', () => { and: [ { createdAt: { - lte: '2024-09-17T23:59:59.999Z', + lte: '2024-09-17T20:46:59.999Z', }, }, { createdAt: { - gte: '2024-09-17T00:00:00.000Z', + gte: '2024-09-17T20:46:00.000Z', }, }, ], diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getDateFilterDisplayValue.spec.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getDateFilterDisplayValue.spec.ts deleted file mode 100644 index d4a1ad81144..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getDateFilterDisplayValue.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getDateFilterDisplayValue } from '../getDateFilterDisplayValue'; - -describe('getDateFilterDisplayValue', () => { - beforeAll(() => { - const mockDate = new Date('2025-06-13T14:30:00Z'); - - // Mocking responses for date methods to avoid timezone issues - jest - .spyOn(mockDate, 'toLocaleString') - .mockReturnValue('6/13/2025, 2:30:00 PM'); - jest.spyOn(mockDate, 'toLocaleDateString').mockReturnValue('6/13/2025'); - - global.Date = jest.fn(() => mockDate) as any; - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - it('should return date and time for DATE_TIME field type', () => { - const date = new Date('2025-06-13T14:30:00Z'); - const result = getDateFilterDisplayValue(date, 'DATE_TIME'); - expect(result).toEqual({ displayValue: '6/13/2025, 2:30:00 PM' }); - }); - - it('should return only date for DATE field type', () => { - const date = new Date('2025-06-13T14:30:00Z'); - const result = getDateFilterDisplayValue(date, 'DATE'); - expect(result).toEqual({ displayValue: '6/13/2025' }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getDateFilterDisplayValue.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getDateFilterDisplayValue.ts deleted file mode 100644 index 1ac1f3f0a8c..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getDateFilterDisplayValue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; - -export const getDateFilterDisplayValue = ( - value: Date, - fieldType: FilterableAndTSVectorFieldType, -) => { - const displayValue = - fieldType === 'DATE_TIME' - ? value.toLocaleString() - : value.toLocaleDateString(); - - return { displayValue }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx index a818b863ac2..0f2430fc28e 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx @@ -17,7 +17,6 @@ import { createPortal } from 'react-dom'; import { isDefined } from 'twenty-shared/utils'; type RecordInlineCellAnchoredPortalProps = { - position: number; fieldMetadataItem: Pick< FieldMetadataItem, 'id' | 'name' | 'type' | 'createdAt' | 'updatedAt' | 'label' diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx index fe27fb951cb..9d37504ac2a 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx @@ -1,11 +1,18 @@ import { type FieldDateMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation'; import { UserContext } from '@/users/contexts/UserContext'; +import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; import { EllipsisDisplay } from './EllipsisDisplay'; +const StyledTimeZoneSpacer = styled.span` + min-width: ${({ theme }) => theme.spacing(1)}; +`; + type DateTimeDisplayProps = { value: string | null | undefined; dateFieldSettings?: FieldDateMetadataSettings; @@ -27,5 +34,16 @@ export const DateTimeDisplay = ({ localeCatalog: dateLocale.localeCatalog, }); - return {formattedDate}; + return ( + + {formattedDate} + + {isNonEmptyString(value) && ( + <> + + + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx index f467be99251..84c4ca5c8e2 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx @@ -1,11 +1,11 @@ import { useRef, useState } from 'react'; import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents'; +import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker'; import { - DateTimePicker, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, -} from '@/ui/input/components/internal/date/components/InternalDatePicker'; +} from '@/ui/input/components/internal/date/components/DateTimePicker'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; import { useRecoilCallback } from 'recoil'; @@ -13,18 +13,17 @@ import { type Nullable } from 'twenty-ui/utilities'; export type DateInputProps = { instanceId: string; - value: Nullable; - onEnter: (newDate: Nullable) => void; - onEscape: (newDate: Nullable) => void; + value: Nullable; + onEnter: (newDate: Nullable) => void; + onEscape: (newDate: Nullable) => void; onClickOutside: ( event: MouseEvent | TouchEvent, - newDate: Nullable, + newDate: Nullable, ) => void; clearable?: boolean; - onChange?: (newDate: Nullable) => void; - isDateTimeInput?: boolean; + onChange?: (newDate: Nullable) => void; onClear?: () => void; - onSubmit?: (newDate: Nullable) => void; + onSubmit?: (newDate: Nullable) => void; hideHeaderInput?: boolean; }; @@ -36,7 +35,6 @@ export const DateInput = ({ onClickOutside, clearable, onChange, - isDateTimeInput, onClear, onSubmit, hideHeaderInput, @@ -45,7 +43,7 @@ export const DateInput = ({ const wrapperRef = useRef(null); - const handleChange = (newDate: Date | null) => { + const handleChange = (newDate: string | null) => { setInternalValue(newDate); onChange?.(newDate); }; @@ -55,7 +53,7 @@ export const DateInput = ({ onClear?.(); }; - const handleClose = (newDate: Date | null) => { + const handleClose = (newDate: string | null) => { setInternalValue(newDate); onSubmit?.(newDate); }; @@ -110,7 +108,7 @@ export const DateInput = ({ return (
-
); diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx new file mode 100644 index 00000000000..86e04b8a946 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx @@ -0,0 +1,123 @@ +import { useRef, useState } from 'react'; + +import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents'; +import { + DateTimePicker, + MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, + MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, +} from '@/ui/input/components/internal/date/components/DateTimePicker'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { useRecoilCallback } from 'recoil'; +import { type Nullable } from 'twenty-ui/utilities'; + +export type DateTimeInputProps = { + instanceId: string; + value: Nullable; + onEnter: (newDateTime: Nullable) => void; + onEscape: (newDateTime: Nullable) => void; + onClickOutside: ( + event: MouseEvent | TouchEvent, + newDateTime: Nullable, + ) => void; + clearable?: boolean; + onChange?: (newDateTime: Nullable) => void; + onClear?: () => void; + onSubmit?: (newDateTime: Nullable) => void; + hideHeaderInput?: boolean; +}; + +export const DateTimeInput = ({ + instanceId, + value, + onEnter, + onEscape, + onClickOutside, + clearable, + onChange, + onClear, + onSubmit, + hideHeaderInput, +}: DateTimeInputProps) => { + const [internalValue, setInternalValue] = useState(value); + + const wrapperRef = useRef(null); + + const handleChange = (newDateTime: Date | null) => { + setInternalValue(newDateTime); + onChange?.(newDateTime); + }; + + const handleClear = () => { + setInternalValue(null); + onClear?.(); + }; + + const handleClose = (newDateTime: Date | null) => { + setInternalValue(newDateTime); + onSubmit?.(newDateTime); + }; + + const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); + const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); + + const handleEnter = () => { + closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + + onEnter(internalValue); + }; + + const handleEscape = () => { + closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + + onEscape(internalValue); + }; + + const handleClickOutside = useRecoilCallback( + ({ snapshot }) => + (event: MouseEvent | TouchEvent) => { + const currentFocusId = snapshot + .getLoadable(currentFocusIdSelector) + .getValue(); + + if (currentFocusId === instanceId) { + closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + onClickOutside(event, internalValue); + } + }, + [ + instanceId, + closeDropdownYearSelect, + closeDropdownMonthSelect, + onClickOutside, + internalValue, + ], + ); + + useRegisterInputEvents({ + focusId: instanceId, + inputRef: wrapperRef, + inputValue: internalValue, + onEnter: handleEnter, + onEscape: handleEscape, + onClickOutside: handleClickOutside, + }); + + 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/DatePicker.tsx similarity index 75% rename from packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx rename to packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx index e25f671a02a..7530f91e5ef 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/DatePicker.tsx @@ -1,5 +1,4 @@ import styled from '@emotion/styled'; -import { addMonths, setDate, setMonth, setYear, subMonths } from 'date-fns'; import { lazy, Suspense, type ComponentType } from 'react'; import type { ReactDatePickerProps as ReactDatePickerLibProps } from 'react-datepicker'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; @@ -8,19 +7,23 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLo import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CalendarStartDay } from '@/localization/constants/CalendarStartDay'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; -import { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader'; -import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput'; +import { DatePickerHeader } from '@/ui/input/components/internal/date/components/DatePickerHeader'; import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; +import { addMonths, setMonth, setYear, subMonths } from 'date-fns'; import 'react-datepicker/dist/react-datepicker.css'; import { useRecoilValue } from 'recoil'; + +import { type Nullable } from 'twenty-shared/types'; import { - type VariableDateViewFilterValueDirection, - type VariableDateViewFilterValueUnit, -} from 'twenty-shared/types'; + getDateFromPlainDate, + getPlainDateFromDate, + isDefined, + type RelativeDateFilter, +} from 'twenty-shared/utils'; import { IconCalendarX } from 'twenty-ui/display'; import { MenuItemLeftContent, @@ -32,8 +35,10 @@ export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID = export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID = 'date-picker-month-and-year-dropdown-year-select'; +const DATE_PICKER_CONTAINER_WIDTH = 280; + const StyledContainer = styled.div<{ calendarDisabled?: boolean }>` - width: 280px; + width: ${DATE_PICKER_CONTAINER_WIDTH}px; & .react-datepicker { border-color: ${({ theme }) => theme.border.color.light}; @@ -295,32 +300,22 @@ const StyledDatePickerFallback = styled.div` width: 280px; `; -type DateTimePickerProps = { +type DatePickerProps = { isRelative?: boolean; hideHeaderInput?: boolean; - date: Date | null; - relativeDate?: { - direction: VariableDateViewFilterValueDirection; - amount?: number; - unit: VariableDateViewFilterValueUnit; + date: Nullable; + relativeDate?: RelativeDateFilter & { + start: string; + end: string; }; - highlightedDateRange?: { - start: Date; - end: Date; - }; - onClose?: (date: Date | null) => void; - onChange?: (date: Date | null) => void; + onClose?: (date: string | null) => void; + onChange?: (date: string | null) => void; onRelativeDateChange?: ( - relativeDate: { - direction: VariableDateViewFilterValueDirection; - amount?: number; - unit: VariableDateViewFilterValueUnit; - } | null, + relativeDateFilter: RelativeDateFilter | null, ) => void; clearable?: boolean; - isDateTimeInput?: boolean; - onEnter?: (date: Date | null) => void; - onEscape?: (date: Date | null) => void; + onEnter?: (date: string | null) => void; + onEscape?: (date: string | null) => void; keyboardEventsDisabled?: boolean; onClear?: () => void; }; @@ -336,20 +331,19 @@ const ReactDatePicker = lazy>(() => })), ); -export const DateTimePicker = ({ +export const DatePicker = ({ date, onChange, onClose, clearable = true, - isDateTimeInput, onClear, isRelative, relativeDate, onRelativeDateChange, - highlightedDateRange, hideHeaderInput, -}: DateTimePickerProps) => { - const internalDate = date ?? new Date(); +}: DatePickerProps) => { + const dateOrToday = date ?? getPlainDateFromDate(new Date()); + const shiftedDateForReactPicker = getDateFromPlainDate(dateOrToday); const theme = useTheme(); @@ -366,85 +360,76 @@ export const DateTimePicker = ({ closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); }; - const handleClose = (newDate: Date) => { + const handleClose = (newDate: string) => { closeDropdowns(); onClose?.(newDate); }; const handleChangeMonth = (month: number) => { - const newDate = new Date(internalDate); - newDate.setMonth(month); - onChange?.(newDate); + const newDate = setMonth(shiftedDateForReactPicker, month); + + const plainDate = getPlainDateFromDate(newDate); + + onChange?.(plainDate); }; const handleAddMonth = () => { - const dateParsed = addMonths(internalDate, 1); - onChange?.(dateParsed); + const dateParsed = addMonths(shiftedDateForReactPicker, 1); + + const plainDate = getPlainDateFromDate(dateParsed); + + onChange?.(plainDate); }; const handleSubtractMonth = () => { - const dateParsed = subMonths(internalDate, 1); - onChange?.(dateParsed); + const dateParsed = subMonths(shiftedDateForReactPicker, 1); + + const plainDate = getPlainDateFromDate(dateParsed); + + onChange?.(plainDate); }; const handleChangeYear = (year: number) => { - const dateParsed = setYear(internalDate, year); - onChange?.(dateParsed); + const dateParsed = setYear(shiftedDateForReactPicker, year); + + const plainDate = getPlainDateFromDate(dateParsed); + + onChange?.(plainDate); }; const handleDateChange = (date: Date) => { - let dateParsed = setYear(internalDate, date.getFullYear()); - dateParsed = setMonth(dateParsed, date.getMonth()); - dateParsed = setDate(dateParsed, date.getDate()); + const plainDate = getPlainDateFromDate(date); - onChange?.(dateParsed); + onChange?.(plainDate); }; const handleDateSelect = (date: Date) => { - let dateParsed = setYear(internalDate, date.getFullYear()); - dateParsed = setMonth(dateParsed, date.getMonth()); - dateParsed = setDate(dateParsed, date.getDate()); + const plainDate = getPlainDateFromDate(date); - handleClose?.(dateParsed); + handleClose?.(plainDate); }; - const dateWithoutTime = new Date( - internalDate.getUTCFullYear(), - internalDate.getUTCMonth(), - internalDate.getUTCDate(), - 0, - 0, - 0, - 0, - ); + const highlightedDates = + isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start) + ? getHighlightedDates({ + start: getDateFromPlainDate(relativeDate.start), + end: getDateFromPlainDate(relativeDate.end), + }) + : []; - // We have to force a end of day on the computer local timezone with the given date - // Because JS Date API cannot hold a timezone other than the local one - // And if we don't do that workaround we will have problems when changing the date - // Because the shown date will have 1 day more or less than the real date - // Leading to bugs where we select 1st of January and it shows 31st of December for example - const endOfDayInLocalTimezone = new Date( - internalDate.getFullYear(), - internalDate.getMonth(), - internalDate.getDate(), - 23, - 59, - 59, - 999, - ); - - const dateToUse = isDateTimeInput ? endOfDayInLocalTimezone : dateWithoutTime; - - const highlightedDates = getHighlightedDates(highlightedDateRange); - - const hasDate = date != null; + const dateAsDate = isDefined(date) ? getDateFromPlainDate(date) : undefined; const selectedDates = isRelative ? highlightedDates - : hasDate - ? [dateToUse] + : isDefined(dateAsDate) + ? [dateAsDate] : []; + const calendarStartDay = + currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM + ? CalendarStartDay[detectCalendarStartDay()] + : (currentWorkspaceMember?.calendarStartDay ?? undefined); + return (
@@ -454,23 +439,23 @@ export const DateTimePicker = ({ - + @@ -478,24 +463,12 @@ export const DateTimePicker = ({ > - } + onChange={handleDateChange} + calendarStartDay={calendarStartDay} renderCustomHeader={({ prevMonthButtonDisabled, nextMonthButtonDisabled, @@ -508,8 +481,8 @@ export const DateTimePicker = ({ onChange={onRelativeDateChange} /> ) : ( - ) diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx new file mode 100644 index 00000000000..b31bd2a8b13 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx @@ -0,0 +1,110 @@ +import styled from '@emotion/styled'; + +import { Select } from '@/ui/input/components/Select'; + +import { DatePickerInput } from '@/ui/input/components/internal/date/components/DatePickerInput'; +import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions'; +import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; +import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display'; +import { LightIconButton } from 'twenty-ui/input'; +import { + MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, + MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, +} from './DateTimePicker'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { parse } from 'date-fns'; +import { useRecoilValue } from 'recoil'; +import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; + +const StyledCustomDatePickerHeader = styled.div` + align-items: center; + display: flex; + justify-content: flex-end; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + padding-top: ${({ theme }) => theme.spacing(2)}; + + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const years = Array.from( + { length: 200 }, + (_, i) => new Date().getFullYear() + 50 - i, +).map((year) => ({ label: year.toString(), value: year })); + +type DatePickerHeaderProps = { + date: string | null; + onChange?: (date: string | null) => void; + onChangeMonth: (month: number) => void; + onChangeYear: (year: number) => void; + onAddMonth: () => void; + onSubtractMonth: () => void; + prevMonthButtonDisabled: boolean; + nextMonthButtonDisabled: boolean; + hideInput?: boolean; +}; + +export const DatePickerHeader = ({ + date, + onChange, + onChangeMonth, + onChangeYear, + onAddMonth, + onSubtractMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + hideInput = false, +}: DatePickerHeaderProps) => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; + + const dateParsed = date ? parse(date, DATE_TYPE_FORMAT, new Date()) : null; + + return ( + <> + {!hideInput && } + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerInput.tsx similarity index 50% rename from packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx rename to packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerInput.tsx index f306bd56c85..0af8d390c28 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerInput.tsx @@ -5,14 +5,15 @@ import { useIMask } from 'react-imask'; import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; import { DATE_BLOCKS } from '@/ui/input/components/internal/date/constants/DateBlocks'; -import { DATE_TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/DateTimeBlocks'; import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate'; import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'; +import { useParseDateInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateInputStringToJSDate'; +import { useParsePlainDateToDateInputString } from '@/ui/input/components/internal/date/hooks/useParsePlainDateToDateInputString'; import { getDateMask } from '@/ui/input/components/internal/date/utils/getDateMask'; -import { getDateTimeMask } from '@/ui/input/components/internal/date/utils/getDateTimeMask'; -import { isNull } from '@sniptt/guards'; + +import { useParseDateInputStringToPlainDate } from '@/ui/input/components/internal/date/hooks/useParseDateInputStringToPlainDate'; +import { useParseJSDateToIMaskDateInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateInputString'; import { isDefined } from 'twenty-shared/utils'; -import { useDateParser } from '../../hooks/useDateParser'; const StyledInputContainer = styled.div` align-items: center; @@ -40,35 +41,37 @@ const StyledInput = styled.input<{ hasError?: boolean }>` `}; `; -type DateTimeInputProps = { - onChange?: (date: Date | null) => void; - date: Date | null; - isDateTimeInput?: boolean; +type DatePickerInputProps = { + onChange?: (date: string | null) => void; + date: string | null; }; -export const DateTimeInput = ({ - date, - onChange, - isDateTimeInput, -}: DateTimeInputProps) => { - const [hasError, setHasError] = useState(false); +export const DatePickerInput = ({ date, onChange }: DatePickerInputProps) => { const { dateFormat } = useDateTimeFormat(); - const { parseToString, parseToDate } = useDateParser({ - isDateTimeInput: isDateTimeInput === true, - }); - const handleParseStringToDate = (str: string) => { - const date = parseToDate(str); + const [internalDate, setInternalDate] = useState(date); - setHasError(isNull(date) === true); + const { parseDateInputStringToPlainDate } = + useParseDateInputStringToPlainDate(); + const { parseDateInputStringToJSDate } = useParseDateInputStringToJSDate(); + const { parsePlainDateToDateInputString } = + useParsePlainDateToDateInputString(); - return date; + const { parseIMaskJSDateIMaskDateInputString } = + useParseJSDateToIMaskDateInputString(); + + const parseIMaskDateInputStringToJSDate = (newDateAsString: string) => { + const newDate = parseDateInputStringToJSDate(newDateAsString); + + return newDate; }; - const pattern = isDateTimeInput - ? getDateTimeMask(dateFormat) - : getDateMask(dateFormat); - const blocks = isDateTimeInput ? DATE_TIME_BLOCKS : DATE_BLOCKS; + const pattern = getDateMask(dateFormat); + const blocks = DATE_BLOCKS; + + const defaultValue = internalDate + ? (parsePlainDateToDateInputString(internalDate) ?? undefined) + : undefined; const { ref, setValue, value } = useIMask( { @@ -77,30 +80,27 @@ export const DateTimeInput = ({ blocks, min: MIN_DATE, max: MAX_DATE, - format: (date: any) => parseToString(date), - parse: handleParseStringToDate, + format: (date: any) => parseIMaskJSDateIMaskDateInputString(date), + parse: parseIMaskDateInputStringToJSDate, lazy: false, autofix: true, }, { - onComplete: (value) => { - const parsedDate = parseToDate(value); + defaultValue, + onComplete: (newValue) => { + const parsedDate = parseDateInputStringToPlainDate(newValue); onChange?.(parsedDate); }, - onAccept: () => { - setHasError(false); - }, }, ); useEffect(() => { - if (!isDefined(date)) { - return; + if (isDefined(date) && internalDate !== date) { + setInternalDate(date); + setValue(parsePlainDateToDateInputString(date)); } - - setValue(parseToString(date)); - }, [date, setValue, parseToString]); + }, [date, internalDate, parsePlainDateToDateInputString, setValue]); return ( @@ -109,7 +109,6 @@ export const DateTimeInput = ({ ref={ref as any} value={value} onChange={() => {}} // Prevent React warning - hasError={hasError} /> ); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx new file mode 100644 index 00000000000..b172fe4d89e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx @@ -0,0 +1,519 @@ +import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; +import { CalendarStartDay } from '@/localization/constants/CalendarStartDay'; +import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; +import { DateTimePickerHeader } from '@/ui/input/components/internal/date/components/DateTimePickerHeader'; +import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; +import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { addMonths, setMonth, setYear, subMonths } from 'date-fns'; +import { lazy, Suspense, type ComponentType } from 'react'; +import type { ReactDatePickerProps as ReactDatePickerLibProps } from 'react-datepicker'; +import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; + +import 'react-datepicker/dist/react-datepicker.css'; + +import { IconCalendarX } from 'twenty-ui/display'; +import { + MenuItemLeftContent, + StyledHoverableMenuItemBase, +} from 'twenty-ui/navigation'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useTurnPointInTimeIntoReactDatePickerShiftedDate } from '@/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate'; +import { useTurnReactDatePickerShiftedDateBackIntoPointInTime } from '@/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime'; +import { useRecoilValue } from 'recoil'; +import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils'; + +export const MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID = + 'date-picker-month-and-year-dropdown-month-select'; +export const MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID = + 'date-picker-month-and-year-dropdown-year-select'; + +const StyledContainer = styled.div<{ + calendarDisabled?: boolean; +}>` + width: 280px; + + & .react-datepicker { + border-color: ${({ theme }) => theme.border.color.light}; + background: transparent; + font-family: 'Inter'; + font-size: ${({ theme }) => theme.font.size.md}; + border: none; + display: block; + font-weight: ${({ theme }) => theme.font.weight.regular}; + } + + & .react-datepicker-popper { + position: relative !important; + inset: auto !important; + transform: none !important; + padding: 0 !important; + } + + & .react-datepicker__triangle { + display: none; + } + + & .react-datepicker__triangle::after { + display: none; + } + + & .react-datepicker__triangle::before { + display: none; + } + + & .react-datepicker-wrapper { + display: none; + } + + // Header + + & .react-datepicker__header { + background: transparent; + border: none; + padding: 0; + } + + & + .react-datepicker__input-time-container + .react-datepicker-time__input-container + .react-datepicker-time__input { + outline: none; + } + + & .react-datepicker__header__dropdown { + display: flex; + color: ${({ theme }) => theme.font.color.primary}; + margin-left: ${({ theme }) => theme.spacing(1)}; + margin-bottom: ${({ theme }) => theme.spacing(10)}; + } + + & .react-datepicker__month-dropdown-container, + & .react-datepicker__year-dropdown-container { + text-align: left; + border-radius: ${({ theme }) => theme.border.radius.sm}; + margin-left: ${({ theme }) => theme.spacing(1)}; + margin-right: 0; + padding: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(4)}; + background-color: ${({ theme }) => theme.background.tertiary}; + } + + & .react-datepicker__month-read-view--down-arrow, + & .react-datepicker__year-read-view--down-arrow { + height: 5px; + width: 5px; + border-width: 1px 1px 0 0; + border-color: ${({ theme }) => theme.border.color.light}; + top: 3px; + right: -6px; + } + + & .react-datepicker__year-read-view, + & .react-datepicker__month-read-view { + padding-right: ${({ theme }) => theme.spacing(2)}; + } + + & .react-datepicker__month-dropdown-container { + width: 80px; + } + + & .react-datepicker__year-dropdown-container { + width: 50px; + } + + & .react-datepicker__month-dropdown, + & .react-datepicker__year-dropdown { + overflow-y: scroll; + top: ${({ theme }) => theme.spacing(2)}; + } + & .react-datepicker__month-dropdown { + left: ${({ theme }) => theme.spacing(2)}; + height: 260px; + } + + & .react-datepicker__year-dropdown { + left: calc(${({ theme }) => theme.spacing(9)} + 80px); + width: 100px; + height: 260px; + } + + & .react-datepicker__navigation--years { + display: none; + } + + & .react-datepicker__month-option--selected, + & .react-datepicker__year-option--selected { + display: none; + } + + & .react-datepicker__year-option, + & .react-datepicker__month-option { + text-align: left; + padding: ${({ theme }) => theme.spacing(2)} + calc(${({ theme }) => theme.spacing(2)} - 2px); + width: calc(100% - ${({ theme }) => theme.spacing(4)}); + border-radius: ${({ theme }) => theme.border.radius.xs}; + color: ${({ theme }) => theme.font.color.secondary}; + cursor: pointer; + margin: 2px; + + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } + } + + & .react-datepicker__year-option { + &:first-of-type, + &:last-of-type { + display: none; + } + } + + & .react-datepicker__current-month { + display: none; + } + + & .react-datepicker__day-name { + color: ${({ theme }) => theme.font.color.secondary}; + width: 34px; + height: 40px; + line-height: 40px; + } + + & .react-datepicker__month-container { + float: none; + } + + // Days + + & .react-datepicker__month { + margin-top: 0; + + pointer-events: ${({ calendarDisabled }) => + calendarDisabled ? 'none' : 'auto'}; + opacity: ${({ calendarDisabled }) => (calendarDisabled ? '0.5' : '1')}; + } + + & .react-datepicker__day { + width: 34px; + height: 34px; + line-height: 34px; + } + + & .react-datepicker__navigation--previous, + & .react-datepicker__navigation--next { + height: 34px; + border-radius: ${({ theme }) => theme.border.radius.sm}; + padding-top: 6px; + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } + } + & .react-datepicker__navigation--previous { + right: 38px; + top: 6px; + left: auto; + + & > span { + margin-left: -6px; + } + } + + & .react-datepicker__navigation--next { + right: 6px; + top: 6px; + + & > span { + margin-left: 6px; + } + } + + & .react-datepicker__navigation-icon::before { + height: 7px; + width: 7px; + border-width: 1px 1px 0 0; + border-color: ${({ theme }) => theme.font.color.tertiary}; + } + + & .react-datepicker__day--keyboard-selected { + background-color: inherit; + } + + & .react-datepicker__day, + .react-datepicker__time-name { + color: ${({ theme }) => theme.font.color.primary}; + } + + & .react-datepicker__day--selected { + background-color: ${({ theme }) => theme.color.blue}; + color: ${({ theme }) => theme.background.primary}; + + &.react-datepicker__day:hover { + color: ${({ theme }) => theme.background.primary}; + } + } + + & .react-datepicker__day--outside-month { + color: ${({ theme }) => theme.font.color.tertiary}; + } + + & .react-datepicker__day:hover { + color: ${({ theme }) => theme.font.color.tertiary}; + } +`; + +const StyledSeparator = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + width: 100%; +`; + +const StyledButtonContainer = styled(StyledHoverableMenuItemBase)` + box-sizing: border-box; + height: 32px; + margin: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(1)}; + width: auto; +`; + +const StyledButton = styled(MenuItemLeftContent)` + justify-content: start; +`; + +const StyledDatePickerFallback = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.secondary}; + border-radius: ${({ theme }) => theme.border.radius.md}; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + height: 300px; + justify-content: center; + padding: ${({ theme }) => theme.spacing(4)}; + width: 280px; +`; + +type DateTimePickerProps = { + isRelative?: boolean; + hideHeaderInput?: boolean; + date: Date | null; + relativeDate?: RelativeDateFilter & { + start: Date; + end: Date; + }; + onClose?: (date: Date | null) => void; + onChange?: (date: Date | null) => void; + onRelativeDateChange?: ( + relativeDateFilter: RelativeDateFilter | null, + ) => void; + clearable?: boolean; + onEnter?: (date: Date | null) => void; + onEscape?: (date: Date | null) => void; + keyboardEventsDisabled?: boolean; + onClear?: () => void; +}; + +type DatePickerPropsType = ReactDatePickerLibProps< + boolean | undefined, + boolean | undefined +>; + +const ReactDatePicker = lazy>(() => + import('react-datepicker').then((mod) => ({ + default: mod.default as unknown as ComponentType, + })), +); + +export const DateTimePicker = ({ + date, + onChange, + onClose, + clearable = true, + onClear, + isRelative, + relativeDate, + onRelativeDateChange, + hideHeaderInput, +}: DateTimePickerProps) => { + const theme = useTheme(); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + + const { turnReactDatePickerShiftedDateBackIntoPointInTime } = + useTurnReactDatePickerShiftedDateBackIntoPointInTime(); + const { turnPointInTimeIntoReactDatePickerShiftedDate } = + useTurnPointInTimeIntoReactDatePickerShiftedDate(); + + const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); + const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); + + const handleClear = () => { + closeDropdowns(); + onClear?.(); + }; + + const closeDropdowns = () => { + closeDropdownYearSelect(MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID); + closeDropdownMonthSelect(MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID); + }; + + const handleClose = (newDate: Date) => { + closeDropdowns(); + onClose?.(newDate); + }; + + const handleChangeMonth = (month: number) => { + const newDateTz = setMonth(reactPickerShiftedDate, month); + + const normalDate = + turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz); + + onChange?.(normalDate); + }; + + const handleAddMonth = () => { + const newDateTz = addMonths(reactPickerShiftedDate, 1); + + const normalDate = + turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz); + + onChange?.(normalDate); + }; + + const handleSubtractMonth = () => { + const newDateTz = subMonths(reactPickerShiftedDate, 1); + + const normalDate = + turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz); + + onChange?.(normalDate); + }; + + const handleChangeYear = (year: number) => { + const newDateTz = setYear(reactPickerShiftedDate, year); + + const normalDate = + turnReactDatePickerShiftedDateBackIntoPointInTime(newDateTz); + + onChange?.(normalDate); + }; + + const handleDateChange = (newDate: Date) => { + const normalDate = + turnReactDatePickerShiftedDateBackIntoPointInTime(newDate); + + onChange?.(normalDate); + }; + + const handleDateSelect = (newReactDatePickerShiftedDateSelected: Date) => { + const normalDate = turnReactDatePickerShiftedDateBackIntoPointInTime( + newReactDatePickerShiftedDateSelected, + ); + + handleClose?.(normalDate); + }; + + const highlightedDates = + isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start) + ? getHighlightedDates({ + end: turnPointInTimeIntoReactDatePickerShiftedDate(relativeDate?.end), + start: turnPointInTimeIntoReactDatePickerShiftedDate( + relativeDate?.start, + ), + }) + : []; + + const reactPickerShiftedDate = turnPointInTimeIntoReactDatePickerShiftedDate( + date ?? new Date(), + ); + + const selectedDates = isRelative + ? highlightedDates + : [reactPickerShiftedDate]; + + return ( + + + + + + + + + + } + > + + isRelative ? ( + + ) : ( + + ) + } + onSelect={handleDateSelect} + selectsMultiple={isRelative} + /> + + {clearable && ( + <> + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx similarity index 81% rename from packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx rename to packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx index 961a92c48dd..46bba14b65e 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerHeader.tsx @@ -3,8 +3,8 @@ import { useRecoilValue } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Select } from '@/ui/input/components/Select'; -import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput'; +import { DateTimePickerInput } from '@/ui/input/components/internal/date/components/DateTimePickerInput'; import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions'; import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; @@ -13,7 +13,7 @@ import { LightIconButton } from 'twenty-ui/input'; import { MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, -} from './InternalDatePicker'; +} from './DateTimePicker'; const StyledCustomDatePickerHeader = styled.div` align-items: center; @@ -31,7 +31,7 @@ const years = Array.from( (_, i) => new Date().getFullYear() + 50 - i, ).map((year) => ({ label: year.toString(), value: year })); -type AbsoluteDatePickerHeaderProps = { +type DateTimePickerHeaderProps = { date: Date; onChange?: (date: Date | null) => void; onChangeMonth: (month: number) => void; @@ -40,11 +40,10 @@ type AbsoluteDatePickerHeaderProps = { onSubtractMonth: () => void; prevMonthButtonDisabled: boolean; nextMonthButtonDisabled: boolean; - isDateTimeInput?: boolean; hideInput?: boolean; }; -export const AbsoluteDatePickerHeader = ({ +export const DateTimePickerHeader = ({ date, onChange, onChangeMonth, @@ -53,32 +52,14 @@ export const AbsoluteDatePickerHeader = ({ onSubtractMonth, prevMonthButtonDisabled, nextMonthButtonDisabled, - isDateTimeInput, hideInput = false, -}: AbsoluteDatePickerHeaderProps) => { +}: DateTimePickerHeaderProps) => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; - const endOfDayInLocalTimezone = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate(), - 23, - 59, - 59, - 999, - ); - return ( <> - {!hideInput && ( - - )} - + {!hideInput && } @@ -101,7 +82,7 @@ export const AbsoluteDatePickerHeader = ({ { - setDirection(newDirection); - if (props.amount === undefined && newDirection !== 'THIS') return; - props.onChange?.({ + if (amount === undefined && newDirection !== 'THIS') { + return; + } + + onChange?.({ direction: newDirection, - amount: props.amount, + amount: amount, unit: unit, }); }} options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS} fullWidth - disabled={props.readonly} + disabled={readonly} />