From afeb505eed57cd2fcdb939f2b889f5de8a2de57b Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Sat, 1 Nov 2025 18:31:24 +0100 Subject: [PATCH] [Breaking Change] Implement reliable date picker utils to handle all timezone combinations (#15377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements the necessary tools to have `react-datepicker` calendar and our date picker components work reliably no matter the timezone difference between the user execution environment and the user application timezone. Fixes https://github.com/twentyhq/core-team-issues/issues/1781 This PR won't cover everything needed to have Twenty handle timezone properly, here is the follow-up issue : https://github.com/twentyhq/core-team-issues/issues/1807 # Features in this PR This PR brings a lot of features that have to be merged together. - DATE field type is now handled as string only, because it shouldn't involve timezone nor the JS Date object at all, since it is a day like a birthday date, and not an absolute point in time. - DATE_TIME field wasn't properly handled when the user settings timezone was different from the system one - A timezone abbreviation suffix has been added to most DATE_TIME display component, only when the timezone is different from the system one in the settings. - A lot of bugs, small features and improvements have been made here : https://github.com/twentyhq/core-team-issues/issues/1781 # Handling of timezones ## Essential concepts This topic is so complex and easy to misunderstand that it is necessary to define the precise terms and concepts first. It resembles character encoding and should be treated with the same care. - Wall-clock time : the time expressed in the timezone of a user, it is distinct from the absolute point in time it points to, much like a pointer being a different value than the value that it points to. - Absolute time : a point in time, regardless of the timezone, it is an objective point in time, of course it has to be expressed in a given timezone, because we have to talk about when it is located in time between humans, but it is in fact distinct from any wall clock time, it exists in itself without any clock running on earth. However, by convention the low-level way to store an absolute point in time is in UTC, which is a timezone, because there is no way to store an absolute point in time without a referential, much like a point in space cannot be stored without a referential. - DST : Daylight Save Time, makes the timezone shift in a specific period every year in a given timezone, to make better use of longer days for various reasons, not all timezones have DST. DST can be 1 hour or 30 min, 45 min, which makes computation difficult. - UTC : It is NOT an “absolute timezone”, it is the wall-clock time at 0° longitude without DST, which is an arbitrary and shared human convention. UTC is often used as the standard reference wall-clock time for talking about absolute point in time without having to do timezone and DST arithmetic. PostgreSQL stores everything in UTC by convention, but outputs everything in the server’s SESSION TIMEZONE. ## How should an absolute point in time be stored ? Since an absolute point in time is essentially distinct from its timezone it could be stored in an absolute way, but in practice it is impossible to store an absolute point in time without a referential. We have to say that a rocket launched at X given time, in UTC, EST, CET, etc. And of course, someone in China will say that it launched at 10:30, while in San Francisco it will have launched at 19:30, but it is THE SAME absolute point in time. Let’s take a related example in computer science with character encoding. If a text is stored without the associated encoding table, the correct meaning associated to the bits stored in memory can be lost forever. It can become impossible for a program to guess what encoding table should be used for a given text stored as bits, thus the glitches that appeared a lot back in the early days of internet and document processing. The same can happen with date time storing, if we don’t have the timezone associated with the absolute point in time, the information of when it absolutely happened is lost. It is NOT necessary to store an absolute point in time in UTC, it is more of a standard and practical wall-clock time to be associated with an absolute point in time. But an absolute point in time MUST be store with a timezone, with its time referential, otherwise the information of when it absolutely happened is lost. For example, it is easier to pass around a date as a string in UTC, like `2024-01-02T00:00:00Z` because it allows front-end and back-end code to “talk” in the same standard and DST-free wall-clock time, BUT it is not necessary. Because we have date libraries that operate on the standard ISO timezone tables, we can talk in different timezone and let the libraries handle the conversion internally. It is false to say that UTC is an absolute timezone or an absolute point in time, it is just the standard, conventional time referential, because one can perfectly store every absolute points in time in UTC+10 with a complex DST table and have the exactly correct absolute points in time, without any loss of information, without having any UTC+0 dates involved. Thus storing an absolute point in time without a timezone associated, for example with `timestamp` PostgreSQL data type, is equivalent to storing a wall-clock time and then throwing away voluntarily the information that allows to know when it absolutely happened, which is a voluntary data-loss if the code that stores and retrieves those wall-clock points in time don’t store the associated timezone somewhere. This is why we use `timestamptz` type in PostgreSQL, so that we make sure that the correct absolute point in time is stored at the exact time we send it to PostgreSQL server, no matter the front-end, back-end and SQL server's timezone differences. ## The JavaScript Date object The native JavaScript Date object is now officially considered legacy ([source](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date)), the Date object stores an absolute point in time BUT it forces the storage to use its execution environment timezone, and one CANNOT modify this timezone, this is a legacy behavior. To obtain the desired result and store an absolute point in time with an arbitrary timezone there are several options : - The new Temporal API that is the successor of the legacy Date object. - Moment / Luxon / @date-fns/tz that expose objects that allow to use any timezone to store an absolute point in time. ## How PostgreSQL stores absolute point in times PostgreSQL stores absolute points in time internally in UTC ([source](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-INPUT-TIME-STAMPS)), but the output date is expressed in the server’s session timezone ([source](https://www.postgresql.org/docs/current/sql-set.html)) which can be different from UTC. Example with the object companies in Twenty seed database, on a local instance, with a new “datetime” custom column : image image ## Why can’t I just use the JavaScript native Date object with some manual logic ? Because the JavaScript Date object does not allow to change its internal timezone, the libraries that are based on it will behave on the execution environment timezone, thus leading to bugs that appear only on the computers of users in a timezone but not for other in another timezone. In our case the `react-datepicker` library forces to use the `Date` object, thus forcing the calendar to behave in the execution environment system timezone, which causes a lot of problems when we decide to display the Twenty application DATE_TIME values in another timezone than the user system one, the bugs that appear will be of the off-by-one date class, for example clicking on 23 will select 24, thus creating an unreliable feature for some system / application timezone combinations. A solution could be to manually compute the difference of minutes between the application user and the system timezones, but that’s not reliable because of DST which makes this computation unreliable when DST are applied at different period of the year for the two timezones. ## Why can’t I compute the timezone difference manually ? Because of DST, the work to compute the timezone difference reliably, not just for the usual happy path, is equivalent to developing the internal mechanism of a date timezone library, which is equivalent to use a library that handles timezones. ## Using `@date-fns/tz` to solve this problem We could have used `luxon` but it has a heavy bundle size, so instead we rely here on `@date-fns/tz` (~1kB) which gives us a `TZDate` object that allows to use any given timezone to store an absolute point-in-time. The solution here is to trick `react-datepicker` by shifting a Date object by the difference of timezone between the user application timezone and the system timezone. Let’s take a concerte example. System timezone : Midway, ⇒ UTC-11:00, has no DST. User application timezone : Auckland, NZ ⇒ UTC+13:00, has a DST. We’ll take the NZ daylight time, so that will make a timezone difference of 24 hours ! Let’s take an error-prone date : `2025-01-01T00:00:00` . This date is usually a good test-case because it can generate three classes of bugs : off-by-one day bugs, off-by-one month bugs and off-by-one year bugs, at the same time. Here is the absolute point in time we take expressed in the different wall-clock time points we manipulate Case | In system timezone ⇒ UTC-11 | In UTC | In user application timezone ⇒ UTC+13 -- | -- | -- | -- Original date | `2024-12-31T00:00:00-11:00` | `2024-12-31T11:00:00Z` | `2025-01-01T00:00:00+13:00` Date shifted for react-datepicker | `2025-01-01T00:00:00-11:00` | `2025-01-01T11:00:00Z` | `2025-01-02T00:00:00+13:00` We can see with this table that we have the number part of the date that is the same (`2025-01-01T00:00:00`) but with a different timezone to “trick” `react-datepicker` and have it display the correct day in its calendar. You can find the code in the hooks `useTurnPointInTimeIntoReactDatePickerShiftedDate` and `useTurnReactDatePickerShiftedDateBackIntoPointInTime` that contain the logic that produces the above table internally. ## Miscellaneous Removed FormDateFieldInput and FormDateTimeFieldInput stories as they do not behave the same depending of the execution environment and it would be easier to put them back after having refactored FormDateFieldInput and FormDateTimeFieldInput --------- Co-authored-by: Charles Bochet --- package.json | 1 + .../src/generated-metadata/graphql.ts | 23 +- .../twenty-front/src/generated/graphql.ts | 23 +- .../utils/detection/detectCalendarStartDay.ts | 8 +- .../utils/formatDateISOStringToDateTime.ts | 2 + .../AdvancedFilterDropdownFilterInput.tsx | 7 +- ...SelectFieldUsedInAdvancedFilterDropdown.ts | 3 +- .../ObjectFilterDropdownDateInput.tsx | 112 ++-- .../ObjectFilterDropdownDateTimeInput.tsx | 134 +++++ .../ObjectFilterDropdownFilterInput.tsx | 13 +- .../useApplyObjectFilterDropdownOperand.ts | 56 +- .../hooks/useGetDateTimeFilterDisplayValue.ts | 49 ++ .../hooks/useGetInitialFilterValue.ts | 115 ++++ ...seGetNowInUserTimezoneForRelativeFilter.ts | 33 ++ .../utils/getInitialFilterValue.ts | 40 -- .../utils/getRelativeDateDisplayValue.ts | 13 +- .../RecordBoardCardCellEditModePortal.tsx | 1 - .../RecordBoardCardCellHoveredPortal.tsx | 10 +- .../components/RecordCalendarTopBar.tsx | 3 +- .../components/RecordCalendarMonthBody.tsx | 3 +- .../components/RecordCalendarMonthBodyDay.tsx | 3 +- .../RecordCalendarCardCellEditModePortal.tsx | 1 - .../RecordCalendarCardCellHoveredPortal.tsx | 1 - .../RecordFieldListCellEditModePortal.tsx | 1 - .../RecordFieldListCellHoveredPortal.tsx | 1 - .../components/FormDateFieldInput.tsx | 360 +++++++++++- .../components/FormDateTimeFieldInput.tsx | 31 +- .../components/FormRelativeDatePicker.tsx | 17 +- .../FormDateFieldInput.stories.tsx | 409 -------------- .../FormDateTimeFieldInput.stories.tsx | 463 ---------------- .../DateTimeFieldDisplay.perf.stories.tsx | 2 +- .../input/components/DateFieldInput.tsx | 37 +- .../input/components/DateTimeFieldInput.tsx | 6 +- ...eEmptyRecordFilterFromFieldMetadataItem.ts | 4 +- ...omputeViewRecordGqlOperationFilter.test.ts | 4 +- .../getDateFilterDisplayValue.spec.ts | 31 -- .../utils/getDateFilterDisplayValue.ts | 13 - .../RecordInlineCellAnchoredPortal.tsx | 1 - .../display/components/DateTimeDisplay.tsx | 20 +- .../ui/field/input/components/DateInput.tsx | 25 +- .../field/input/components/DateTimeInput.tsx | 123 +++++ ...{InternalDatePicker.tsx => DatePicker.tsx} | 192 +++---- .../date/components/DatePickerHeader.tsx | 110 ++++ ...{DateTimeInput.tsx => DatePickerInput.tsx} | 75 ++- .../date/components/DateTimePicker.tsx | 519 ++++++++++++++++++ ...kerHeader.tsx => DateTimePickerHeader.tsx} | 35 +- .../date/components/DateTimePickerInput.tsx | 113 ++++ .../components/RelativeDatePickerHeader.tsx | 90 ++- .../date/components/TimeZoneAbbreviation.tsx | 32 ++ .../InternalDatePicker.stories.tsx | 2 +- .../RelativeDateDirectionSelectOptions.ts | 4 +- .../RelativeDateUnitSelectOptions.ts | 8 +- .../hooks/__tests__/useUserTimezone.test.tsx | 113 ++++ .../hooks/useParseDateInputStringToJSDate.ts | 23 + .../useParseDateInputStringToPlainDate.ts | 26 + .../useParseDateTimeInputStringToJSDate.ts | 25 + .../useParseJSDateToIMaskDateInputString.ts | 20 + ...seParseJSDateToIMaskDateTimeInputString.ts | 18 + .../useParsePlainDateToDateInputString.ts | 23 + ...intInTimeIntoReactDatePickerShiftedDate.ts | 29 + ...atePickerShiftedDateBackIntoPointInTime.ts | 33 ++ .../internal/date/hooks/useUserDateFormat.ts | 14 + .../internal/date/hooks/useUserTimeFormat.ts | 14 + .../internal/date/hooks/useUserTimezone.ts | 25 + .../date/utils/parseDateTimeToString.ts | 26 + .../internal/date/utils/parseDateToString.ts | 32 -- .../internal/date/utils/parseStringToDate.ts | 34 -- .../internal/hooks/useDateParser.ts | 43 -- .../views/components/EditableFilterChip.tsx | 5 +- ...eComputeRecordRelationFilterLabelValue.tsx | 5 +- .../hooks/useGetRecordFilterLabelValue.ts | 149 +++++ ...ldMetadataItemFromViewBarFilterDropdown.ts | 13 +- .../views/utils/getRecordFilterLabelValue.ts | 56 -- .../stringifyRelativeDateFilter.test.ts | 401 ++++++++++++++ .../computeVariableDateViewFilterValue.ts | 20 - .../utils/stringifyRelativeDateFilter.ts | 37 ++ .../__tests__/getBodyTypeFromHeaders.test.ts | 126 +++++ packages/twenty-front/src/utils/date-utils.ts | 25 +- .../input/date-time-filter.input-type.ts | 19 + .../graphql-types/scalars/date.scalar.ts | 19 +- .../graphql-types/scalars/index.ts | 1 + .../services/type-mapper.service.ts | 5 +- .../search/dtos/object-record-filter-input.ts | 42 +- .../twenty-orm/utils/format-result.util.ts | 49 +- ...evaluate-relative-date-filter.util.spec.ts | 41 +- ...-and-evaluate-relative-date-filter.util.ts | 92 ++-- ...r-input-by-field-metadata-type.constant.ts | 15 +- .../src/constants/DateTypeFormat.ts | 1 + .../DefaultRelativeDateFilterValue.ts | 7 + packages/twenty-shared/src/constants/index.ts | 2 + .../src/types/RecordGqlOperationFilter.ts | 17 +- .../src/types/RelativeDateValue.ts | 15 - packages/twenty-shared/src/types/index.ts | 7 +- .../safeParseRelativeDateFilterValue.test.ts | 92 ++-- .../filter/dates/utils/addUnitToDateTime.ts | 19 + .../dates/utils/firstDayOfWeekSchema.ts | 5 + .../dates/utils/getDateFromPlainDate.ts | 6 + .../dates/utils/getEndUnitOfDateTime.ts | 33 ++ ...getFirstDayOfTheWeekAsANumberForDateFNS.ts | 17 + .../dates/utils/getPlainDateFromDate.ts | 6 + .../dates/utils/getStartUnitOfDateTime.ts | 33 ++ .../utils/relativeDateFilterAmountSchema.ts | 5 + .../relativeDateFilterDirectionSchema.ts | 11 + .../dates/utils/relativeDateFilterSchema.ts | 21 + .../relativeDateFilterStringifiedSchema.ts | 39 ++ .../utils/relativeDateFilterUnitSchema.ts | 12 + .../filter/dates/utils/resolveDateFilter.ts | 26 + .../dates/utils/resolveDateTimeFilter.ts | 25 + .../dates/utils/resolveRelativeDateFilter.ts | 53 ++ .../resolveRelativeDateFilterStringified.ts | 20 + .../utils/resolveRelativeDateTimeFilter.ts | 51 ++ ...esolveRelativeDateTimeFilterStringified.ts | 44 ++ ...neDifferenceInMinutesWithSystemTimezone.ts | 26 + .../filter/dates/utils/subUnitFromDateTime.ts | 19 + .../twenty-shared/src/utils/filter/index.ts | 1 - .../turnRecordFilterIntoGqlOperationFilter.ts | 133 ++++- .../computeTimezoneDifferenceInMinutes.ts | 12 + .../utils/resolveDateViewFilterValue.ts | 188 ------- packages/twenty-shared/src/utils/index.ts | 39 +- ...eParseRelativeDateFilterJSONStringified.ts | 22 + .../utils/safeParseRelativeDateFilterValue.ts | 52 -- yarn.lock | 8 + 122 files changed, 3958 insertions(+), 2109 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetNowInUserTimezoneForRelativeFilter.ts delete mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getInitialFilterValue.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getDateFilterDisplayValue.spec.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/getDateFilterDisplayValue.ts create mode 100644 packages/twenty-front/src/modules/ui/field/input/components/DateTimeInput.tsx rename packages/twenty-front/src/modules/ui/input/components/internal/date/components/{InternalDatePicker.tsx => DatePicker.tsx} (75%) create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerHeader.tsx rename packages/twenty-front/src/modules/ui/input/components/internal/date/components/{DateTimeInput.tsx => DatePickerInput.tsx} (50%) create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx rename packages/twenty-front/src/modules/ui/input/components/internal/date/components/{AbsoluteDatePickerHeader.tsx => DateTimePickerHeader.tsx} (81%) create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerInput.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/TimeZoneAbbreviation.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useUserTimezone.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseDateInputStringToJSDate.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseDateInputStringToPlainDate.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateInputString.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParsePlainDateToDateInputString.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserDateFormat.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimeFormat.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateTimeToString.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseStringToDate.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/hooks/useDateParser.ts create mode 100644 packages/twenty-front/src/modules/views/hooks/useGetRecordFilterLabelValue.ts delete mode 100644 packages/twenty-front/src/modules/views/utils/getRecordFilterLabelValue.ts create mode 100644 packages/twenty-front/src/modules/views/view-filter-value/utils/__tests__/stringifyRelativeDateFilter.test.ts delete mode 100644 packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts create mode 100644 packages/twenty-front/src/modules/views/view-filter-value/utils/stringifyRelativeDateFilter.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/utils/__tests__/getBodyTypeFromHeaders.test.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/date-time-filter.input-type.ts create mode 100644 packages/twenty-shared/src/constants/DateTypeFormat.ts create mode 100644 packages/twenty-shared/src/constants/DefaultRelativeDateFilterValue.ts delete mode 100644 packages/twenty-shared/src/types/RelativeDateValue.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/addUnitToDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/firstDayOfWeekSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getDateFromPlainDate.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getEndUnitOfDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getPlainDateFromDate.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getStartUnitOfDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterAmountSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterDirectionSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterStringifiedSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterUnitSchema.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveDateFilter.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveDateTimeFilter.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilterStringified.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/subUnitFromDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/utils/computeTimezoneDifferenceInMinutes.ts delete mode 100644 packages/twenty-shared/src/utils/filter/utils/resolveDateViewFilterValue.ts create mode 100644 packages/twenty-shared/src/utils/safeParseRelativeDateFilterJSONStringified.ts delete mode 100644 packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts 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} />