[Breaking Change] Implement reliable date picker utils to handle all timezone combinations (#15377)

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 :

<img width="374" height="554" alt="image"
src="https://github.com/user-attachments/assets/4394cb43-d97e-4479-801d-ca068f800e39"
/>

<img width="516" height="524" alt="image"
src="https://github.com/user-attachments/assets/b652f36a-d2e2-47a4-8950-647ca688cbbd"
/>

## 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 <charles@twenty.com>
This commit is contained in:
Lucas Bordeau 2025-11-01 18:31:24 +01:00 committed by GitHub
parent 3a203040f7
commit afeb505eed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
122 changed files with 3958 additions and 2109 deletions

View file

@ -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",

View file

@ -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<Scalars['Date']>;
gt?: InputMaybe<Scalars['Date']>;
gte?: InputMaybe<Scalars['Date']>;
in?: InputMaybe<Array<Scalars['Date']>>;
export type DateTimeFilter = {
eq?: InputMaybe<Scalars['DateTime']>;
gt?: InputMaybe<Scalars['DateTime']>;
gte?: InputMaybe<Scalars['DateTime']>;
in?: InputMaybe<Array<Scalars['DateTime']>>;
is?: InputMaybe<FilterIs>;
lt?: InputMaybe<Scalars['Date']>;
lte?: InputMaybe<Scalars['Date']>;
neq?: InputMaybe<Scalars['Date']>;
lt?: InputMaybe<Scalars['DateTime']>;
lte?: InputMaybe<Scalars['DateTime']>;
neq?: InputMaybe<Scalars['DateTime']>;
};
export type DeleteApprovedAccessDomainInput = {
@ -2912,12 +2911,12 @@ export type ObjectPermissionInput = {
export type ObjectRecordFilterInput = {
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
createdAt?: InputMaybe<DateFilter>;
deletedAt?: InputMaybe<DateFilter>;
createdAt?: InputMaybe<DateTimeFilter>;
deletedAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<UuidFilter>;
not?: InputMaybe<ObjectRecordFilterInput>;
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
updatedAt?: InputMaybe<DateFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
};
export type ObjectStandardOverrides = {

View file

@ -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<Scalars['Date']>;
gt?: InputMaybe<Scalars['Date']>;
gte?: InputMaybe<Scalars['Date']>;
in?: InputMaybe<Array<Scalars['Date']>>;
export type DateTimeFilter = {
eq?: InputMaybe<Scalars['DateTime']>;
gt?: InputMaybe<Scalars['DateTime']>;
gte?: InputMaybe<Scalars['DateTime']>;
in?: InputMaybe<Array<Scalars['DateTime']>>;
is?: InputMaybe<FilterIs>;
lt?: InputMaybe<Scalars['Date']>;
lte?: InputMaybe<Scalars['Date']>;
neq?: InputMaybe<Scalars['Date']>;
lt?: InputMaybe<Scalars['DateTime']>;
lte?: InputMaybe<Scalars['DateTime']>;
neq?: InputMaybe<Scalars['DateTime']>;
};
export type DeleteApprovedAccessDomainInput = {
@ -2823,12 +2822,12 @@ export type ObjectPermissionInput = {
export type ObjectRecordFilterInput = {
and?: InputMaybe<Array<ObjectRecordFilterInput>>;
createdAt?: InputMaybe<DateFilter>;
deletedAt?: InputMaybe<DateFilter>;
createdAt?: InputMaybe<DateTimeFilter>;
deletedAt?: InputMaybe<DateTimeFilter>;
id?: InputMaybe<UuidFilter>;
not?: InputMaybe<ObjectRecordFilterInput>;
or?: InputMaybe<Array<ObjectRecordFilterInput>>;
updatedAt?: InputMaybe<DateFilter>;
updatedAt?: InputMaybe<DateTimeFilter>;
};
export type ObjectStandardOverrides = {

View file

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

View file

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

View file

@ -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 = ({
<AdvancedFilterDropdownTextInput recordFilter={recordFilter} />
))}
{filterType === 'RATING' && <ObjectFilterDropdownRatingInput />}
{DATE_FILTER_TYPES.includes(filterType) && (
<ObjectFilterDropdownDateInput />
)}
{filterType === 'DATE_TIME' && <ObjectFilterDropdownDateTimeInput />}
{filterType === 'DATE' && <ObjectFilterDropdownDateInput />}
{filterType === 'RELATION' && (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<ObjectFilterDropdownSearchInput />

View file

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

View file

@ -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<Date | null>(
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 (
<DateTimePicker
<DatePicker
relativeDate={relativeDate}
highlightedDateRange={relativeDate}
isRelative={isRelativeOperand}
date={internalDate}
date={plainDateValue ?? null}
onChange={handleAbsoluteDateChange}
onRelativeDateChange={handleRelativeDateChange}
isDateTimeInput={isDateTimeInput}
onClear={handleClear}
/>
);

View file

@ -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<Date | null>(
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 (
<DateTimePicker
relativeDate={relativeDate}
isRelative={isRelativeOperand}
date={internalDate}
onChange={handleAbsoluteDateChange}
onRelativeDateChange={handleRelativeDateChange}
onClear={handleClear}
/>
);
};

View file

@ -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 = ({
<ObjectFilterDropdownInnerSelectOperandDropdown />
</>
);
} else if (isDateFilter) {
} else if (filterType === 'DATE') {
return (
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
@ -86,6 +85,14 @@ export const ObjectFilterDropdownFilterInput = ({
<ObjectFilterDropdownDateInput />
</>
);
} else if (filterType === 'DATE_TIME') {
return (
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
<ObjectFilterDropdownDateTimeInput />
</>
);
} else {
return (
<>

View file

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

View file

@ -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,
};
};

View file

@ -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<RecordFilter, 'value' | 'displayValue'> | Record<string, never> => {
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,
};
};

View file

@ -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,
};
};

View file

@ -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<RecordFilter, 'value' | 'displayValue'> | Record<string, never> => {
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: '',
};
};

View file

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

View file

@ -28,7 +28,6 @@ export const RecordBoardCardCellEditModePortal = () => {
return (
<RecordInlineCellAnchoredPortal
position={editModePosition}
fieldMetadataItem={editedFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -1,12 +1,9 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardCardCellHoveredPortalContent } from '@/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardCellHoveredPortalContent';
import { RecordBoardCardInputContextProvider } from '@/object-record/record-board/record-board-card/anchored-portal/components/RecordBoardCardInputContextProvider';
import { RECORD_BOARD_CARD_INPUT_ID_PREFIX } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardInputIdPrefix';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
import { useRecordBoardCardMetadataFromPosition } from '@/object-record/record-board/record-board-card/hooks/useRecordBoardCardMetadataFromPosition';
import { recordBoardCardHoverPositionComponentState } from '@/object-record/record-board/record-board-card/states/recordBoardCardHoverPositionComponentState';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
@ -15,19 +12,14 @@ export const RecordBoardCardCellHoveredPortal = () => {
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 (
<RecordInlineCellAnchoredPortal
position={hoverPosition}
fieldMetadataItem={hoveredFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -1,7 +1,7 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { DateTimePicker } from '@/ui/input/components/internal/date/components/DateTimePicker';
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -121,7 +121,6 @@ export const RecordCalendarTopBar = () => {
<DropdownContent widthInPixels={280}>
<DateTimePicker
date={recordCalendarSelectedDate}
isDateTimeInput={false}
onChange={handleDateChange}
onClose={handleDateChange}
onEnter={handleDateChange}

View file

@ -2,6 +2,7 @@ import { RecordCalendarMonthBodyWeek } from '@/object-record/record-calendar/mon
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import styled from '@emotion/styled';
import { format } from 'date-fns';
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
const StyledContainer = styled.div`
display: flex;
@ -19,7 +20,7 @@ export const RecordCalendarMonthBody = () => {
<StyledContainer>
{weekFirstDays.map((weekFirstDay) => (
<RecordCalendarMonthBodyWeek
key={`week-${format(weekFirstDay, 'yyyy-MM-dd')}`}
key={`week-${format(weekFirstDay, DATE_TYPE_FORMAT)}`}
startDayOfWeek={weekFirstDay}
/>
))}

View file

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

View file

@ -32,7 +32,6 @@ export const RecordCalendarCardCellEditModePortal = ({
return (
<RecordInlineCellAnchoredPortal
position={editModePosition}
fieldMetadataItem={editedFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -31,7 +31,6 @@ export const RecordCalendarCardCellHoveredPortal = ({
return (
<RecordInlineCellAnchoredPortal
position={hoverPosition}
fieldMetadataItem={hoveredFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -38,7 +38,6 @@ export const RecordFieldListCellEditModePortal = ({
return (
<RecordInlineCellAnchoredPortal
position={editModePosition}
fieldMetadataItem={editedFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -37,7 +37,6 @@ export const RecordFieldListCellHoveredPortal = ({
return (
<RecordInlineCellAnchoredPortal
position={hoverPosition}
fieldMetadataItem={hoveredFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}

View file

@ -1,13 +1,89 @@
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker';
import {
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { useParseDateInputStringToPlainDate } from '@/ui/input/components/internal/date/hooks/useParseDateInputStringToPlainDate';
import { useParsePlainDateToDateInputString } from '@/ui/input/components/internal/date/hooks/useParsePlainDateToDateInputString';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import {
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
import { type Nullable } from 'twenty-ui/utilities';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
const StyledInputContainer = styled(FormFieldInputInnerContainer)`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0;
overflow: visible;
position: relative;
`;
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
top: ${({ theme }) => 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<DraftValue>(
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<Nullable<string>>(draftValueAsDate);
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const [inputDate, setInputDate] = useState(
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parsePlainDateToDateInputString(draftValueAsDate)
: '',
);
const persistDate = (newDate: Nullable<string>) => {
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<string>) => {
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<string>) => {
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<HTMLInputElement>) => {
setInputDate(event.target.value);
};
const handleInputKeydown = (event: KeyboardEvent<HTMLInputElement>) => {
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 (
<FormDateTimeFieldInput
dateOnly
label={label}
defaultValue={defaultValue}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder={placeholder}
/>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledInputContainer
formFieldInputInstanceId={instanceId}
ref={datePickerWrapperRef}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInput
type="text"
placeholder={placeholderToDisplay}
value={inputDate}
onFocus={handleInputFocus}
onChange={handleInputChange}
onKeyDown={handleInputKeydown}
disabled={readonly}
/>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<DatePicker
date={pickerDate}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
/>
</OverlayContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</StyledInputContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View file

@ -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<DraftValue>(
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 = ({
<OverlayContainer>
<DateTimePicker
date={pickerDate ?? new Date()}
isDateTimeInput={false}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}

View file

@ -1,11 +1,12 @@
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
import { isNonEmptyString, isString } from '@sniptt/guards';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import {
DEFAULT_RELATIVE_DATE_VALUE,
type VariableDateViewFilterValue,
} from 'twenty-shared/types';
import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils';
type RelativeDateFilter,
safeParseRelativeDateFilterJSONStringified,
} from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
export type FormRelativeDatePickerProps = {
@ -22,10 +23,10 @@ export const FormRelativeDatePicker = ({
}: FormRelativeDatePickerProps) => {
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}

View file

@ -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<typeof FormDateFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateFieldInput',
component: FormDateFieldInput,
args: {},
argTypes: {},
decorators: [I18nFrontDecorator, WorkflowStepDecorator],
};
export default meta;
type Story = StoryObj<typeof FormDateFieldInput>;
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}}
>
Add variable
</button>
);
},
},
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}}
>
Add variable
</button>
);
},
},
decorators: [WorkflowStepDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const variableChip = await canvas.findByText('Creation date');
expect(variableChip).toBeVisible();
},
};

View file

@ -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<typeof FormDateTimeFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInput',
component: FormDateTimeFieldInput,
args: {},
argTypes: {},
decorators: [I18nFrontDecorator, WorkflowStepDecorator],
};
export default meta;
type Story = StoryObj<typeof FormDateTimeFieldInput>;
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}}
>
Add variable
</button>
);
},
},
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 (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.createdAt}}`);
}}
>
Add variable
</button>
);
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const variableChip = await canvas.findByText('Creation date');
expect(variableChip).toBeVisible();
},
};

View file

@ -50,7 +50,7 @@ export const Elipsis: Story = {
export const Performance = getProfilingStory({
componentName: 'DateTimeFieldDisplay',
averageThresholdInMs: 0.1,
averageThresholdInMs: 0.15,
numberOfRuns: 30,
numberOfTestsPerRun: 30,
});

View file

@ -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<Date>) => {
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<string>) => {
onEnter?.({ newValue: newDate });
};
const handleEnter = (newDate: Nullable<Date>) => {
onEnter?.({ newValue: getDateToPersist(newDate) });
const handleSubmit = (newDate: Nullable<string>) => {
onSubmit?.({ newValue: newDate });
};
const handleSubmit = (newDate: Nullable<Date>) => {
onSubmit?.({ newValue: getDateToPersist(newDate) });
};
const handleEscape = (newDate: Nullable<Date>) => {
onEscape?.({ newValue: getDateToPersist(newDate) });
const handleEscape = (newDate: Nullable<string>) => {
onEscape?.({ newValue: newDate });
};
const handleClickOutside = (
event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
newDate: Nullable<string>,
) => {
onClickOutside?.({ newValue: getDateToPersist(newDate), event });
onClickOutside?.({ newValue: newDate, event });
};
const handleChange = (newDate: Nullable<Date>) => {
setDraftValue(newDate?.toDateString() ?? '');
const handleChange = (newDate: Nullable<string>) => {
setDraftValue(newDate ?? '');
};
const handleClear = () => {
onSubmit?.({ newValue: null });
};
const dateValue = fieldValue ? new Date(fieldValue) : null;
const dateValue = fieldValue ?? null;
return (
<DateInput

View file

@ -1,7 +1,6 @@
import { DateInput } from '@/ui/field/input/components/DateInput';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { DateTimeInput } from '@/ui/field/input/components/DateTimeInput';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext } from 'react';
import { type Nullable } from 'twenty-ui/utilities';
@ -58,7 +57,7 @@ export const DateTimeFieldInput = () => {
const dateValue = fieldValue ? new Date(fieldValue) : null;
return (
<DateInput
<DateTimeInput
instanceId={instanceId}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
@ -66,7 +65,6 @@ export const DateTimeFieldInput = () => {
value={dateValue}
clearable
onChange={handleChange}
isDateTimeInput
onClear={handleClear}
onSubmit={handleSubmit}
/>

View file

@ -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,
) => {

View file

@ -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',
},
},
],

View file

@ -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' });
});
});

View file

@ -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 };
};

View file

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

View file

@ -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 <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;
return (
<EllipsisDisplay>
{formattedDate}
<span></span>
{isNonEmptyString(value) && (
<>
<StyledTimeZoneSpacer />
<TimeZoneAbbreviation date={new Date(value)} />
</>
)}
</EllipsisDisplay>
);
};

View file

@ -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<Date>;
onEnter: (newDate: Nullable<Date>) => void;
onEscape: (newDate: Nullable<Date>) => void;
value: Nullable<string>;
onEnter: (newDate: Nullable<string>) => void;
onEscape: (newDate: Nullable<string>) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
newDate: Nullable<string>,
) => void;
clearable?: boolean;
onChange?: (newDate: Nullable<Date>) => void;
isDateTimeInput?: boolean;
onChange?: (newDate: Nullable<string>) => void;
onClear?: () => void;
onSubmit?: (newDate: Nullable<Date>) => void;
onSubmit?: (newDate: Nullable<string>) => 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<HTMLDivElement>(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 (
<div ref={wrapperRef}>
<DateTimePicker
<DatePicker
date={internalValue ?? null}
onChange={handleChange}
onClose={handleClose}
@ -119,7 +117,6 @@ export const DateInput = ({
onEscape={onEscape}
onClear={handleClear}
hideHeaderInput={hideHeaderInput}
isDateTimeInput={isDateTimeInput}
/>
</div>
);

View file

@ -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<Date>;
onEnter: (newDateTime: Nullable<Date>) => void;
onEscape: (newDateTime: Nullable<Date>) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDateTime: Nullable<Date>,
) => void;
clearable?: boolean;
onChange?: (newDateTime: Nullable<Date>) => void;
onClear?: () => void;
onSubmit?: (newDateTime: Nullable<Date>) => 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<HTMLDivElement>(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 (
<div ref={wrapperRef}>
<DateTimePicker
date={internalValue ?? null}
onChange={handleChange}
onClose={handleClose}
clearable={clearable ?? false}
onEnter={onEnter}
onEscape={onEscape}
onClear={handleClear}
hideHeaderInput={hideHeaderInput}
/>
</div>
);
};

View file

@ -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<string>;
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<ComponentType<DatePickerPropsType>>(() =>
})),
);
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 (
<StyledContainer calendarDisabled={isRelative}>
<div className={clearable ? 'clearable ' : ''}>
@ -454,23 +439,23 @@ export const DateTimePicker = ({
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
borderRadius={2}
>
<Skeleton
width={200}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={240}
width={DATE_PICKER_CONTAINER_WIDTH - 16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={220}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
width={DATE_PICKER_CONTAINER_WIDTH - 16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
width={DATE_PICKER_CONTAINER_WIDTH - 16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={DATE_PICKER_CONTAINER_WIDTH - 16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
</SkeletonTheme>
</StyledDatePickerFallback>
@ -478,24 +463,12 @@ export const DateTimePicker = ({
>
<ReactDatePicker
open={true}
selected={hasDate ? dateToUse : undefined}
selected={shiftedDateForReactPicker}
selectedDates={selectedDates}
openToDate={hasDate ? dateToUse : new Date()}
openToDate={shiftedDateForReactPicker}
disabledKeyboardNavigation
onChange={handleDateChange as any}
calendarStartDay={
currentWorkspaceMember?.calendarStartDay ===
CalendarStartDay.SYSTEM
? CalendarStartDay[detectCalendarStartDay()]
: (currentWorkspaceMember?.calendarStartDay ?? undefined)
}
customInput={
<DateTimeInput
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
/>
}
onChange={handleDateChange}
calendarStartDay={calendarStartDay}
renderCustomHeader={({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
@ -508,8 +481,8 @@ export const DateTimePicker = ({
onChange={onRelativeDateChange}
/>
) : (
<AbsoluteDatePickerHeader
date={internalDate}
<DatePickerHeader
date={dateOrToday}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
@ -517,7 +490,6 @@ export const DateTimePicker = ({
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput}
hideInput={hideHeaderInput}
/>
)

View file

@ -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 && <DatePickerInput date={date} onChange={onChange} />}
<StyledCustomDatePickerHeader>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions(userLocale)}
onChange={onChangeMonth}
value={dateParsed?.getMonth()}
fullWidth
/>
</ClickOutsideListenerContext.Provider>
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
}}
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={dateParsed?.getFullYear()}
options={years}
fullWidth
/>
</ClickOutsideListenerContext.Provider>
<LightIconButton
Icon={IconChevronLeft}
onClick={onSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={onAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
</>
);
};

View file

@ -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 (
<StyledInputContainer>
@ -109,7 +109,6 @@ export const DateTimeInput = ({
ref={ref as any}
value={value}
onChange={() => {}} // Prevent React warning
hasError={hasError}
/>
</StyledInputContainer>
);

View file

@ -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<ComponentType<DatePickerPropsType>>(() =>
import('react-datepicker').then((mod) => ({
default: mod.default as unknown as ComponentType<DatePickerPropsType>,
})),
);
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 (
<StyledContainer calendarDisabled={isRelative}>
<Suspense
fallback={
<StyledDatePickerFallback>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
width={200}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={240}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={220}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</SkeletonTheme>
</StyledDatePickerFallback>
}
>
<ReactDatePicker
open={true}
selected={reactPickerShiftedDate}
selectedDates={selectedDates}
openToDate={reactPickerShiftedDate}
disabledKeyboardNavigation
onChange={handleDateChange}
calendarStartDay={
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
? CalendarStartDay[detectCalendarStartDay()]
: (currentWorkspaceMember?.calendarStartDay ?? undefined)
}
renderCustomHeader={({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) =>
isRelative ? (
<RelativeDatePickerHeader
direction={relativeDate?.direction ?? 'PAST'}
amount={relativeDate?.amount}
unit={relativeDate?.unit ?? 'DAY'}
onChange={onRelativeDateChange}
/>
) : (
<DateTimePickerHeader
date={reactPickerShiftedDate}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
hideInput={hideHeaderInput}
/>
)
}
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</Suspense>
{clearable && (
<>
<StyledSeparator />
<StyledButtonContainer onClick={handleClear}>
<StyledButton LeftIcon={IconCalendarX} text={t`Clear`} />
</StyledButtonContainer>
</>
)}
</StyledContainer>
);
};

View file

@ -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 && (
<DateTimeInput
date={date}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
/>
)}
{!hideInput && <DateTimePickerInput date={date} onChange={onChange} />}
<StyledCustomDatePickerHeader>
<ClickOutsideListenerContext.Provider
value={{
@ -89,7 +70,7 @@ export const AbsoluteDatePickerHeader = ({
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions(userLocale)}
onChange={onChangeMonth}
value={endOfDayInLocalTimezone.getMonth()}
value={date.getMonth()}
fullWidth
/>
</ClickOutsideListenerContext.Provider>
@ -101,7 +82,7 @@ export const AbsoluteDatePickerHeader = ({
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={onChangeYear}
value={endOfDayInLocalTimezone.getFullYear()}
value={date.getFullYear()}
options={years}
fullWidth
/>

View file

@ -0,0 +1,113 @@
import styled from '@emotion/styled';
import { useIMask } from 'react-imask';
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
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 { getDateTimeMask } from '@/ui/input/components/internal/date/utils/getDateTimeMask';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { useParseDateTimeInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate';
import { useParseJSDateToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString';
import { useTurnReactDatePickerShiftedDateBackIntoPointInTime } from '@/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledInputContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
border-top-left-radius: ${({ theme }) => theme.border.radius.md};
border-top-right-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
height: ${({ theme }) => theme.spacing(8)};
width: 100%;
`;
const StyledInput = styled.input<{ hasError?: boolean }>`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
outline: none;
padding-left: ${({ theme }) => theme.spacing(2)};
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
width: 105px;
`;
type DateTimePickerInputProps = {
onChange?: (date: Date | null) => void;
date: Date | null;
};
export const DateTimePickerInput = ({
date,
onChange,
}: DateTimePickerInputProps) => {
const { turnReactDatePickerShiftedDateBackIntoPointInTime } =
useTurnReactDatePickerShiftedDateBackIntoPointInTime();
const [internalDate, setInternalDate] = useState(date);
const { dateFormat } = useDateTimeFormat();
const { parseDateTimeInputStringToJSDate } =
useParseDateTimeInputStringToJSDate();
const { parseJSDateToDateTimeInputString } =
useParseJSDateToIMaskDateTimeInputString();
const handleParseStringToDate = (newDateAsString: string) => {
const date = parseDateTimeInputStringToJSDate(newDateAsString);
return date;
};
const pattern = getDateTimeMask(dateFormat);
const blocks = DATE_TIME_BLOCKS;
const { ref, setValue } = useIMask(
{
mask: Date,
pattern,
blocks,
min: MIN_DATE,
max: MAX_DATE,
format: (date: any) => parseJSDateToDateTimeInputString(date),
parse: handleParseStringToDate,
lazy: false,
autofix: false,
},
{
defaultValue: parseJSDateToDateTimeInputString(
internalDate ?? new Date(),
),
onComplete: (value) => {
const parsedDate = parseDateTimeInputStringToJSDate(value);
if (!isDefined(parsedDate)) {
return;
}
const pointInTime =
turnReactDatePickerShiftedDateBackIntoPointInTime(parsedDate);
onChange?.(pointInTime);
},
},
);
useEffect(() => {
if (isDefined(date) && internalDate !== date) {
setInternalDate(date);
setValue(parseJSDateToDateTimeInputString(date));
}
}, [date, internalDate, parseJSDateToDateTimeInputString, setValue]);
return (
<StyledInputContainer>
<StyledInput type="text" ref={ref as any} />
<TimeZoneAbbreviation date={internalDate ?? new Date()} />
</StyledInputContainer>
);
};

View file

@ -2,14 +2,15 @@ import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions';
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
} from 'twenty-shared/types';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { variableDateViewFilterValuePartsSchema } from 'twenty-shared/utils';
import { type Nullable } from 'twenty-shared/types';
import {
relativeDateFilterSchema,
type RelativeDateFilter,
type RelativeDateFilterDirection,
type RelativeDateFilterUnit,
} from 'twenty-shared/utils';
const StyledContainer = styled.div<{ noPadding: boolean }>`
display: flex;
@ -20,60 +21,54 @@ const StyledContainer = styled.div<{ noPadding: boolean }>`
`;
type RelativeDatePickerHeaderProps = {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
onChange?: (value: {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
}) => void;
direction: RelativeDateFilterDirection;
amount?: Nullable<number>;
unit: RelativeDateFilterUnit;
onChange?: (value: RelativeDateFilter) => void;
isFormField?: boolean;
readonly?: boolean;
unitDropdownWidth?: number;
};
export const RelativeDatePickerHeader = (
props: RelativeDatePickerHeaderProps,
) => {
const [direction, setDirection] = useState(props.direction);
const [amountString, setAmountString] = useState(
props.amount ? props.amount.toString() : '',
);
const [unit, setUnit] = useState(props.unit);
useEffect(() => {
setAmountString(props.amount ? props.amount.toString() : '');
setUnit(props.unit);
setDirection(props.direction);
}, [props.amount, props.unit, props.direction]);
export const RelativeDatePickerHeader = ({
direction,
unit,
amount,
isFormField,
onChange,
readonly,
unitDropdownWidth,
}: RelativeDatePickerHeaderProps) => {
const amountString = amount?.toString() ?? '';
const textInputValue = direction === 'THIS' ? '' : amountString;
const textInputPlaceholder = direction === 'THIS' ? '-' : 'Number';
const isUnitPlural = props.amount && props.amount > 1 && direction !== 'THIS';
const isUnitPlural = amount && amount > 1 && direction !== 'THIS';
const unitSelectOptions = RELATIVE_DATE_UNITS_SELECT_OPTIONS.map((unit) => ({
...unit,
label: `${unit.label}${isUnitPlural ? 's' : ''}`,
}));
return (
<StyledContainer noPadding={props.isFormField ?? false}>
<StyledContainer noPadding={isFormField ?? false}>
<Select
dropdownId="direction-select"
value={direction}
onChange={(newDirection) => {
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}
/>
<SettingsTextInput
instanceId="relative-date-picker-amount"
@ -83,40 +78,37 @@ export const RelativeDatePickerHeader = (
const amountString = text.replace(/[^0-9]|^0+/g, '');
const amount = parseInt(amountString);
setAmountString(amountString);
const valueParts = {
direction,
amount,
unit,
};
if (
variableDateViewFilterValuePartsSchema.safeParse(valueParts)
.success === true
) {
props.onChange?.(valueParts);
if (relativeDateFilterSchema.safeParse(valueParts).success === true) {
onChange?.(valueParts);
}
}}
placeholder={textInputPlaceholder}
disabled={direction === 'THIS' || props.readonly}
disabled={direction === 'THIS' || readonly}
/>
<Select
dropdownId="unit-select"
value={unit}
onChange={(newUnit) => {
setUnit(newUnit);
if (direction !== 'THIS' && props.amount === undefined) return;
props.onChange?.({
if (direction !== 'THIS' && amount === undefined) {
return;
}
onChange?.({
direction,
amount: props.amount,
amount: amount,
unit: newUnit,
});
}}
fullWidth
options={unitSelectOptions}
disabled={props.readonly}
dropdownWidth={props.unitDropdownWidth}
disabled={readonly}
dropdownWidth={unitDropdownWidth}
/>
</StyledContainer>
);

View file

@ -0,0 +1,32 @@
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import styled from '@emotion/styled';
const StyledTimezoneAbbreviation = styled.span<{ hasError?: boolean }>`
background: transparent;
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
width: fit-content;
user-select: none;
line-height: 0.5px;
`;
export const TimeZoneAbbreviation = ({ date }: { date: Date }) => {
const { isSystemTimezone, getTimezoneAbbreviationForPointInTime } =
useUserTimezone();
const shouldShowTimezoneAbbreviation = !isSystemTimezone;
const timezoneSuffix = !isSystemTimezone
? ` ${getTimezoneAbbreviationForPointInTime(date ?? new Date())}`
: '';
if (!shouldShowTimezoneAbbreviation) {
return <></>;
}
return (
<StyledTimezoneAbbreviation>{timezoneSuffix}</StyledTimezoneAbbreviation>
);
};

View file

@ -1,9 +1,9 @@
import { useArgs } from '@storybook/preview-api';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { DateTimePicker } from '../InternalDatePicker';
import { isDefined } from 'twenty-shared/utils';
import { ComponentDecorator } from 'twenty-ui/testing';
import { DateTimePicker } from '../DateTimePicker';
const meta: Meta<typeof DateTimePicker> = {
title: 'UI/Input/Internal/InternalDatePicker',

View file

@ -1,7 +1,7 @@
import { type VariableDateViewFilterValueDirection } from 'twenty-shared/types';
import { type RelativeDateFilterDirection } from 'twenty-shared/utils';
type RelativeDateDirectionOption = {
value: VariableDateViewFilterValueDirection;
value: RelativeDateFilterDirection;
label: string;
};

View file

@ -1,11 +1,11 @@
import { type VariableDateViewFilterValueUnit } from 'twenty-shared/types';
import { type RelativeDateFilterUnit } from 'twenty-shared/utils';
type RelativeDateUnit = {
value: VariableDateViewFilterValueUnit;
type RelativeDateUnitOption = {
value: RelativeDateFilterUnit;
label: string;
};
export const RELATIVE_DATE_UNITS_SELECT_OPTIONS: RelativeDateUnit[] = [
export const RELATIVE_DATE_UNITS_SELECT_OPTIONS: RelativeDateUnitOption[] = [
{ value: 'DAY', label: 'Day' },
{ value: 'WEEK', label: 'Week' },
{ value: 'MONTH', label: 'Month' },

View file

@ -0,0 +1,113 @@
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
describe('useUserTimezone', () => {
const originalIntl = global.Intl;
const mockSystemTimezone = 'America/New_York';
beforeAll(() => {
// Mock Intl.DateTimeFormat to return a consistent system timezone
global.Intl = {
...originalIntl,
DateTimeFormat: jest.fn().mockImplementation(() => ({
resolvedOptions: () => ({ timeZone: mockSystemTimezone }),
})),
} as any;
});
afterAll(() => {
global.Intl = originalIntl;
});
it('should return system timezone when currentWorkspaceMember is null', () => {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
const { result } = renderHook(() => useUserTimezone(), {
wrapper: Wrapper,
});
expect(result.current.userTimezone).toBe(mockSystemTimezone);
expect(result.current.isSystemTimezone).toBe(true);
});
it('should return system timezone when currentWorkspaceMember.timeZone is "system"', () => {
const WrapperWithSystemTimezone = ({
children,
}: {
children: React.ReactNode;
}) => (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(currentWorkspaceMemberState, {
id: 'workspace-member-id',
name: {
firstName: 'John',
lastName: 'Doe',
},
colorScheme: 'Light',
locale: 'en-US',
userEmail: 'john@example.com',
timeZone: 'system',
dateFormat: null,
timeFormat: null,
numberFormat: null,
calendarStartDay: null,
});
}}
>
{children}
</RecoilRoot>
);
const { result } = renderHook(() => useUserTimezone(), {
wrapper: WrapperWithSystemTimezone,
});
expect(result.current.userTimezone).toBe(mockSystemTimezone);
expect(result.current.isSystemTimezone).toBe(true);
});
it('should return user-specific timezone when currentWorkspaceMember.timeZone is set', () => {
const userTimezone = 'Europe/Paris';
const WrapperWithUserTimezone = ({
children,
}: {
children: React.ReactNode;
}) => (
<RecoilRoot
initializeState={(snapshot) => {
snapshot.set(currentWorkspaceMemberState, {
id: 'workspace-member-id',
name: {
firstName: 'John',
lastName: 'Doe',
},
colorScheme: 'Light',
locale: 'en-US',
userEmail: 'john@example.com',
timeZone: userTimezone,
dateFormat: null,
timeFormat: null,
numberFormat: null,
calendarStartDay: null,
});
}}
>
{children}
</RecoilRoot>
);
const { result } = renderHook(() => useUserTimezone(), {
wrapper: WrapperWithUserTimezone,
});
expect(result.current.userTimezone).toBe(userTimezone);
expect(result.current.isSystemTimezone).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { isValid, parse } from 'date-fns';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
export const useParseDateInputStringToJSDate = () => {
const { dateFormat } = useDateTimeFormat();
const parseDateInputStringToJSDate = (dateAsString: string) => {
const parsingFormat = getDateFormatStringForDatePickerInputMask(dateFormat);
const parsedDate = parse(dateAsString, parsingFormat, new Date());
if (!isValid(parsedDate)) {
return null;
}
return parsedDate;
};
return {
parseDateInputStringToJSDate,
};
};

View file

@ -0,0 +1,26 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { format, isValid, parse } from 'date-fns';
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
export const useParseDateInputStringToPlainDate = () => {
const { dateFormat } = useDateTimeFormat();
const parseDateInputStringToPlainDate = (dateAsString: string) => {
const parsingFormat = getDateFormatStringForDatePickerInputMask(dateFormat);
const parsedDate = parse(dateAsString, parsingFormat, new Date());
if (!isValid(parsedDate)) {
return null;
}
const formattedDate = format(parsedDate, DATE_TYPE_FORMAT);
return formattedDate;
};
return {
parseDateInputStringToPlainDate,
};
};

View file

@ -0,0 +1,25 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { isValid, parse } from 'date-fns';
import { getDateTimeFormatStringFoDatePickerInputMask } from '~/utils/date-utils';
export const useParseDateTimeInputStringToJSDate = () => {
const { dateFormat } = useDateTimeFormat();
const parseDateTimeInputStringToJSDate = (dateAsString: string) => {
const parsingFormat =
getDateTimeFormatStringFoDatePickerInputMask(dateFormat);
const referenceDate = new Date();
const parsedDate = parse(dateAsString, parsingFormat, referenceDate);
if (!isValid(parsedDate)) {
return null;
}
return parsedDate;
};
return {
parseDateTimeInputStringToJSDate,
};
};

View file

@ -0,0 +1,20 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { format } from 'date-fns';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
export const useParseJSDateToIMaskDateInputString = () => {
const { dateFormat } = useDateTimeFormat();
const parseIMaskJSDateIMaskDateInputString = (jsDate: Date) => {
const parsingFormat = getDateFormatStringForDatePickerInputMask(dateFormat);
const formattedDate = format(jsDate, parsingFormat);
return formattedDate;
};
return {
parseIMaskJSDateIMaskDateInputString,
};
};

View file

@ -0,0 +1,18 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { format } from 'date-fns';
import { getDateTimeFormatStringFoDatePickerInputMask } from '~/utils/date-utils';
export const useParseJSDateToIMaskDateTimeInputString = () => {
const { dateFormat } = useDateTimeFormat();
const parseJSDateToDateTimeInputString = (date: Date) => {
const parsingFormat =
getDateTimeFormatStringFoDatePickerInputMask(dateFormat);
return format(date, parsingFormat);
};
return {
parseJSDateToDateTimeInputString,
};
};

View file

@ -0,0 +1,23 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { format, parse } from 'date-fns';
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
export const useParsePlainDateToDateInputString = () => {
const { dateFormat } = useDateTimeFormat();
const parsePlainDateToDateInputString = (plainDate: string) => {
const parsingFormat = getDateFormatStringForDatePickerInputMask(dateFormat);
const parsedDate = parse(plainDate, DATE_TYPE_FORMAT, new Date());
const formattedDate = format(parsedDate, parsingFormat);
return formattedDate;
};
return {
parsePlainDateToDateInputString,
};
};

View file

@ -0,0 +1,29 @@
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { TZDate } from '@date-fns/tz';
export const useTurnPointInTimeIntoReactDatePickerShiftedDate = () => {
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const { userTimezone } = useUserTimezone();
// TODO: replace here with shiftPointInTimeToFromTimezoneDifference
const turnPointInTimeIntoReactDatePickerShiftedDate = (pointInTime: Date) => {
const dateSure = new TZDate(pointInTime).withTimeZone(userTimezone);
const shiftedDate = new TZDate(
dateSure.getFullYear(),
dateSure.getMonth(),
dateSure.getDate(),
dateSure.getHours(),
dateSure.getMinutes(),
dateSure.getSeconds(),
systemTimeZone,
);
return shiftedDate;
};
return {
turnPointInTimeIntoReactDatePickerShiftedDate,
};
};

View file

@ -0,0 +1,33 @@
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { TZDate } from '@date-fns/tz';
export const useTurnReactDatePickerShiftedDateBackIntoPointInTime = () => {
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const { userTimezone } = useUserTimezone();
// TODO: replace here with shiftPointInTimeToFromTimezoneDifference
const turnReactDatePickerShiftedDateBackIntoPointInTime = (
reactDatePickerShiftedDate: Date,
) => {
const dateSure = new TZDate(reactDatePickerShiftedDate).withTimeZone(
systemTimeZone,
);
const dateTz = new TZDate(
dateSure.getFullYear(),
dateSure.getMonth(),
dateSure.getDate(),
dateSure.getHours(),
dateSure.getMinutes(),
dateSure.getSeconds(),
userTimezone,
);
return dateTz;
};
return {
turnReactDatePickerShiftedDateBackIntoPointInTime,
};
};

View file

@ -0,0 +1,14 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useRecoilValue } from 'recoil';
import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql';
export const useUserDateFormat = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const userDateFormat =
currentWorkspaceMember?.dateFormat ?? WorkspaceMemberDateFormatEnum.SYSTEM;
return {
userDateFormat,
};
};

View file

@ -0,0 +1,14 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useRecoilValue } from 'recoil';
import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql';
export const useUserTimeFormat = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const userTimeFormat =
currentWorkspaceMember?.timeFormat ?? WorkspaceMemberTimeFormatEnum.SYSTEM;
return {
userTimeFormat,
};
};

View file

@ -0,0 +1,25 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { tzName } from '@date-fns/tz';
import { useRecoilValue } from 'recoil';
export const useUserTimezone = () => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const userTimezone =
currentWorkspaceMember?.timeZone !== 'system'
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const isSystemTimezone = userTimezone === systemTimeZone;
const getTimezoneAbbreviationForPointInTime = (date: Date) => {
return tzName(userTimezone, date, 'short');
};
return {
userTimezone,
isSystemTimezone,
getTimezoneAbbreviationForPointInTime,
};
};

View file

@ -0,0 +1,26 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { isDefined } from 'twenty-shared/utils';
import { getDateTimeFormatStringFoDatePickerInputMask } from '~/utils/date-utils';
type ParseDateTimeToStringArgs = {
date: Date;
userTimezone: string | undefined;
dateFormat?: DateFormat;
};
export const parseDateTimeToString = ({
date,
userTimezone,
dateFormat = DateFormat.MONTH_FIRST,
}: ParseDateTimeToStringArgs) => {
const parsingFormat =
getDateTimeFormatStringFoDatePickerInputMask(dateFormat);
if (isDefined(userTimezone)) {
return formatInTimeZone(date, userTimezone, parsingFormat);
} else {
return format(date, parsingFormat);
}
};

View file

@ -1,32 +0,0 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { isDefined } from 'twenty-shared/utils';
import { getDateFormatString } from '~/utils/date-utils';
type ParseDateToStringArgs = {
date: Date;
isDateTimeInput: boolean;
userTimezone: string | undefined;
dateFormat?: DateFormat;
};
export const parseDateToString = ({
date,
isDateTimeInput,
userTimezone,
dateFormat = DateFormat.MONTH_FIRST,
}: ParseDateToStringArgs) => {
const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput);
if (isDateTimeInput && isDefined(userTimezone)) {
return formatInTimeZone(date, userTimezone, parsingFormat);
} else if (isDateTimeInput) {
return format(date, parsingFormat);
} else {
const dateWithoutTime = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
return format(dateWithoutTime, parsingFormat);
}
};

View file

@ -1,34 +0,0 @@
import { type DateFormat } from '@/localization/constants/DateFormat';
import { isValid, parse } from 'date-fns';
import { zonedTimeToUtc } from 'date-fns-tz';
import { isDefined } from 'twenty-shared/utils';
import { getDateFormatString } from '~/utils/date-utils';
type ParseStringToDateArgs = {
dateAsString: string;
isDateTimeInput: boolean;
userTimezone: string | undefined;
dateFormat: DateFormat;
};
export const parseStringToDate = ({
dateAsString,
isDateTimeInput,
userTimezone,
dateFormat,
}: ParseStringToDateArgs) => {
const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput);
const referenceDate = new Date();
const parsedDate = parse(dateAsString, parsingFormat, referenceDate);
if (!isValid(parsedDate)) {
return null;
}
if (isDateTimeInput && isDefined(userTimezone)) {
return zonedTimeToUtc(parsedDate, userTimezone);
}
return parsedDate;
};

View file

@ -1,43 +0,0 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { UserContext } from '@/users/contexts/UserContext';
import { useCallback, useContext } from 'react';
import { parseDateToString } from '../date/utils/parseDateToString';
import { parseStringToDate } from '../date/utils/parseStringToDate';
type UseDateParserProps = {
isDateTimeInput: boolean;
};
export const useDateParser = ({ isDateTimeInput }: UseDateParserProps) => {
const { dateFormat } = useDateTimeFormat();
const { timeZone } = useContext(UserContext);
const parseToString = useCallback(
(date: Date) => {
return parseDateToString({
date,
isDateTimeInput,
userTimezone: timeZone,
dateFormat,
});
},
[dateFormat, isDateTimeInput, timeZone],
);
const parseToDate = useCallback(
(dateAsString: string) => {
return parseStringToDate({
dateAsString,
isDateTimeInput,
userTimezone: timeZone,
dateFormat,
});
},
[dateFormat, isDateTimeInput, timeZone],
);
return {
parseToString,
parseToDate,
};
};

View file

@ -4,7 +4,8 @@ import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/uti
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isValidSubFieldName } from '@/settings/data-model/utils/isValidSubFieldName';
import { SortOrFilterChip } from '@/views/components/SortOrFilterChip';
import { getRecordFilterLabelValue } from '@/views/utils/getRecordFilterLabelValue';
import { useGetRecordFilterLabelValue } from '@/views/hooks/useGetRecordFilterLabelValue';
import { isNonEmptyString } from '@sniptt/guards';
import { useIcons } from 'twenty-ui/display';
@ -25,6 +26,8 @@ export const EditableFilterChip = ({
recordFilter.fieldMetadataId,
);
const { getRecordFilterLabelValue } = useGetRecordFilterLabelValue();
const FieldMetadataItemIcon = getIcon(fieldMetadataItem.icon);
const recordFilterSubFieldName = recordFilter.subFieldName;

View file

@ -4,7 +4,8 @@ import { getFieldMetadataItemByIdOrThrow } from '@/object-metadata/utils/getFiel
import { MAX_RECORDS_TO_DISPLAY } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
import { getRecordFilterLabelValue } from '@/views/utils/getRecordFilterLabelValue';
import { useGetRecordFilterLabelValue } from '@/views/hooks/useGetRecordFilterLabelValue';
import { t } from '@lingui/core/macro';
import {
arrayOfUuidOrVariableSchema,
@ -21,6 +22,8 @@ export const useComputeRecordRelationFilterLabelValue = ({
}: ObjectFilterDropdownRecordSelectProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const { getRecordFilterLabelValue } = useGetRecordFilterLabelValue();
if (!isDefined(recordFilter.fieldMetadataId)) {
throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined');
}

View file

@ -0,0 +1,149 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { UserContext } from '@/users/contexts/UserContext';
import { isValid } from 'date-fns';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import {
getDateFromPlainDate,
isEmptinessOperand,
parseJson,
relativeDateFilterStringifiedSchema,
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone,
} from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { formatDateString } from '~/utils/string/formatDateString';
export const useGetRecordFilterLabelValue = () => {
const { dateFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const { userTimezone } = useUserTimezone();
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
const getRecordFilterLabelValue = ({
recordFilter,
fieldMetadataOptions,
}: {
recordFilter: RecordFilter;
fieldMetadataOptions?: FieldMetadataItemOption[];
}) => {
const operandLabelShort = getOperandLabelShort(recordFilter.operand);
const operandIsEmptiness = isEmptinessOperand(recordFilter.operand);
const recordFilterIsEmpty = isRecordFilterConsideredEmpty(recordFilter);
if (recordFilter.type === 'DATE') {
switch (recordFilter.operand) {
case RecordFilterOperand.IS: {
const date = getDateFromPlainDate(recordFilter.value);
const shiftedDate =
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone(
date,
userTimezone,
'add',
);
if (!isValid(date)) {
return `${operandLabelShort}`;
}
const formattedDate = formatDateString({
value: shiftedDate.toISOString(),
timeZone,
dateFormat,
localeCatalog: dateLocale.localeCatalog,
});
return `${operandLabelShort} ${formattedDate}`;
}
case RecordFilterOperand.IS_RELATIVE: {
const relativeDateFilter =
relativeDateFilterStringifiedSchema.safeParse(recordFilter.value);
if (!relativeDateFilter.success) {
return `${operandLabelShort}`;
}
const relativeDateDisplayValue = getRelativeDateDisplayValue(
relativeDateFilter.data,
);
return `${operandLabelShort} ${relativeDateDisplayValue}`;
}
case RecordFilterOperand.IS_TODAY:
case RecordFilterOperand.IS_IN_FUTURE:
case RecordFilterOperand.IS_IN_PAST:
return operandLabelShort;
default:
return `${operandLabelShort} ${recordFilter.displayValue}`;
}
} else if (recordFilter.type === 'DATE_TIME') {
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
case RecordFilterOperand.IS_AFTER:
case RecordFilterOperand.IS_BEFORE: {
const pointInTime = new Date(recordFilter.value);
const { displayValue } = getDateTimeFilterDisplayValue(pointInTime);
return `${operandLabelShort} ${displayValue}`;
}
case RecordFilterOperand.IS_RELATIVE: {
const relativeDateFilter =
relativeDateFilterStringifiedSchema.safeParse(recordFilter.value);
if (!relativeDateFilter.success) {
return `${operandLabelShort}`;
}
const relativeDateDisplayValue = getRelativeDateDisplayValue(
relativeDateFilter.data,
);
return `${operandLabelShort} ${relativeDateDisplayValue}`;
}
case RecordFilterOperand.IS_TODAY:
case RecordFilterOperand.IS_IN_FUTURE:
case RecordFilterOperand.IS_IN_PAST:
return operandLabelShort;
default:
return `${operandLabelShort} ${recordFilter.displayValue}`;
}
} else if (
recordFilter.type === 'SELECT' ||
recordFilter.type === 'MULTI_SELECT'
) {
const valueArray = parseJson<string[]>(recordFilter.value);
if (!Array.isArray(valueArray)) {
return '';
}
const optionLabels = valueArray.map(
(value) =>
fieldMetadataOptions?.find((option) => option.value === value)?.label,
);
return `${operandLabelShort} ${optionLabels.join(', ')}`;
}
if (!operandIsEmptiness && !recordFilterIsEmpty) {
return `${operandLabelShort} ${recordFilter.displayValue}`;
}
if (operandIsEmptiness) {
return `${operandLabelShort}`;
}
return recordFilter.displayValue;
};
return { getRecordFilterLabelValue };
};

View file

@ -1,4 +1,5 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
@ -7,12 +8,13 @@ import { selectedOperandInDropdownComponentState } from '@/object-record/object-
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { findDuplicateRecordFilterInNonAdvancedRecordFilters } from '@/object-record/record-filter/utils/findDuplicateRecordFilterInNonAdvancedRecordFilters';
import { getDateFilterDisplayValue } from '@/object-record/record-filter/utils/getDateFilterDisplayValue';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { VIEW_BAR_FILTER_DROPDOWN_ID } from '@/views/constants/ViewBarFilterDropdownId';
import { useRecoilCallback } from 'recoil';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@ -45,6 +47,7 @@ export const useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown =
useUpsertObjectFilterDropdownCurrentFilter();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { getInitialFilterValue } = useGetInitialFilterValue();
const initializeFilterOnFieldMetataItemFromViewBarFilterDropdown =
useRecoilCallback(
@ -106,12 +109,9 @@ export const useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown =
set(selectedOperandInDropdownCallbackState, defaultOperand);
if (filterType === 'DATE' || filterType === 'DATE_TIME') {
const date = new Date();
const value = date.toISOString();
const { displayValue } = getDateFilterDisplayValue(
date,
const { displayValue, value } = getInitialFilterValue(
filterType,
defaultOperand,
);
const initialDateRecordFilter: RecordFilter = {
@ -143,6 +143,7 @@ export const useInitializeFilterOnFieldMetadataItemFromViewBarFilterDropdown =
objectFilterDropdownCurrentRecordFilterCallbackState,
selectedOperandInDropdownCallbackState,
upsertObjectFilterDropdownCurrentFilter,
getInitialFilterValue,
],
);

View file

@ -1,56 +0,0 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { getOperandLabelShort } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { isEmptinessOperand, parseJson } from 'twenty-shared/utils';
export const getRecordFilterLabelValue = ({
recordFilter,
fieldMetadataOptions,
}: {
recordFilter: RecordFilter;
fieldMetadataOptions?: FieldMetadataItemOption[];
}) => {
const operandLabelShort = getOperandLabelShort(recordFilter.operand);
const operandIsEmptiness = isEmptinessOperand(recordFilter.operand);
const recordFilterIsEmpty = isRecordFilterConsideredEmpty(recordFilter);
const isDateOrDateTimeFilter =
recordFilter.type === 'DATE' || recordFilter.type === 'DATE_TIME';
if (isDateOrDateTimeFilter) {
switch (recordFilter.operand) {
case RecordFilterOperand.IS_TODAY:
case RecordFilterOperand.IS_IN_FUTURE:
case RecordFilterOperand.IS_IN_PAST:
return operandLabelShort;
default:
return `${operandLabelShort} ${recordFilter.displayValue}`;
}
}
if (recordFilter.type === 'SELECT' || recordFilter.type === 'MULTI_SELECT') {
const valueArray = parseJson<string[]>(recordFilter.value);
if (!Array.isArray(valueArray)) {
return '';
}
const optionLabels = valueArray.map(
(value) =>
fieldMetadataOptions?.find((option) => option.value === value)?.label,
);
return `${operandLabelShort} ${optionLabels.join(', ')}`;
}
if (!operandIsEmptiness && !recordFilterIsEmpty) {
return `${operandLabelShort} ${recordFilter.displayValue}`;
}
if (operandIsEmptiness) {
return `${operandLabelShort}`;
}
return recordFilter.displayValue;
};

View file

@ -0,0 +1,401 @@
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { type RelativeDateFilter } from 'twenty-shared/utils';
import { stringifyRelativeDateFilter } from '../stringifyRelativeDateFilter';
jest.mock('@/localization/utils/detection/detectCalendarStartDay');
describe('stringifyRelativeDateFilter', () => {
const mockDetectCalendarStartDay =
detectCalendarStartDay as jest.MockedFunction<
typeof detectCalendarStartDay
>;
beforeEach(() => {
jest.clearAllMocks();
mockDetectCalendarStartDay.mockReturnValue('MONDAY');
});
describe('basic stringification', () => {
it('should stringify a PAST relative date filter', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
});
it('should stringify a NEXT relative date filter', () => {
const filter: RelativeDateFilter = {
direction: 'NEXT',
amount: 3,
unit: 'WEEK',
};
expect(stringifyRelativeDateFilter(filter)).toBe('NEXT_3_WEEK');
});
it('should stringify with different units', () => {
const dayFilter: RelativeDateFilter = {
direction: 'PAST',
amount: 1,
unit: 'DAY',
};
const weekFilter: RelativeDateFilter = {
direction: 'PAST',
amount: 1,
unit: 'WEEK',
};
const monthFilter: RelativeDateFilter = {
direction: 'PAST',
amount: 1,
unit: 'MONTH',
};
const yearFilter: RelativeDateFilter = {
direction: 'PAST',
amount: 1,
unit: 'YEAR',
};
expect(stringifyRelativeDateFilter(dayFilter)).toBe('PAST_1_DAY');
expect(stringifyRelativeDateFilter(weekFilter)).toBe('PAST_1_WEEK');
expect(stringifyRelativeDateFilter(monthFilter)).toBe('PAST_1_MONTH');
expect(stringifyRelativeDateFilter(yearFilter)).toBe('PAST_1_YEAR');
});
});
describe('THIS direction handling', () => {
it('should stringify THIS direction with amount 1 regardless of provided amount', () => {
const filter: RelativeDateFilter = {
direction: 'THIS',
amount: 5,
unit: 'WEEK',
};
expect(stringifyRelativeDateFilter(filter)).toBe('THIS_1_WEEK');
});
it('should stringify THIS direction with amount 1 when amount is undefined', () => {
const filter: RelativeDateFilter = {
direction: 'THIS',
amount: undefined,
unit: 'MONTH',
};
expect(stringifyRelativeDateFilter(filter)).toBe('THIS_1_MONTH');
});
it('should stringify THIS direction with amount 1 when amount is null', () => {
const filter: RelativeDateFilter = {
direction: 'THIS',
amount: null,
unit: 'YEAR',
};
expect(stringifyRelativeDateFilter(filter)).toBe('THIS_1_YEAR');
});
});
describe('amount validation', () => {
it('should throw error when amount is undefined for PAST direction', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: undefined,
unit: 'DAY',
};
expect(() => stringifyRelativeDateFilter(filter)).toThrow(
'Amount must be defined and greater than 0 for relative date filters',
);
});
it('should throw error when amount is undefined for NEXT direction', () => {
const filter: RelativeDateFilter = {
direction: 'NEXT',
amount: undefined,
unit: 'WEEK',
};
expect(() => stringifyRelativeDateFilter(filter)).toThrow(
'Amount must be defined and greater than 0 for relative date filters',
);
});
it('should throw error when amount is null for PAST direction', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: null,
unit: 'DAY',
};
expect(() => stringifyRelativeDateFilter(filter)).toThrow(
'Amount must be defined and greater than 0 for relative date filters',
);
});
it('should throw error when amount is 0', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 0,
unit: 'DAY',
};
expect(() => stringifyRelativeDateFilter(filter)).toThrow(
'Amount must be defined and greater than 0 for relative date filters',
);
});
it('should throw error when amount is negative', () => {
const filter: RelativeDateFilter = {
direction: 'NEXT',
amount: -5,
unit: 'WEEK',
};
expect(() => stringifyRelativeDateFilter(filter)).toThrow(
'Amount must be defined and greater than 0 for relative date filters',
);
});
});
describe('timezone handling', () => {
it('should append timezone when provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;',
);
});
it('should not append timezone when not provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
});
it('should not append timezone when it is an empty string', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: '',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
});
it('should not append timezone when it is null', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: null,
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
});
});
describe('referenceDayAsString handling', () => {
it('should append referenceDayAsString when timezone is provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '2024-01-15',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;2024-01-15;;MONDAY;;',
);
});
it('should not append referenceDayAsString when timezone is not provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
referenceDayAsString: '2024-01-15',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
});
it('should not append referenceDayAsString when it is empty', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;',
);
});
it('should not append referenceDayAsString when it is null', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: null,
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;',
);
});
});
describe('firstDayOfTheWeek handling', () => {
it('should append firstDayOfTheWeek when timezone and referenceDayAsString are provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '2024-01-15',
firstDayOfTheWeek: 'SUNDAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;2024-01-15;;SUNDAY;;',
);
});
it('should use detected calendar start day when firstDayOfTheWeek is not provided', () => {
mockDetectCalendarStartDay.mockReturnValue('MONDAY');
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '2024-01-15',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;2024-01-15;;MONDAY;;',
);
expect(mockDetectCalendarStartDay).toHaveBeenCalledTimes(1);
});
it('should use detected calendar start day when firstDayOfTheWeek is null', () => {
mockDetectCalendarStartDay.mockReturnValue('SATURDAY');
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '2024-01-15',
firstDayOfTheWeek: null,
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;2024-01-15;;SATURDAY;;',
);
expect(mockDetectCalendarStartDay).toHaveBeenCalledTimes(1);
});
it('should not append firstDayOfTheWeek when referenceDayAsString is not provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
firstDayOfTheWeek: 'SUNDAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;',
);
expect(mockDetectCalendarStartDay).not.toHaveBeenCalled();
});
it('should not append firstDayOfTheWeek when timezone is not provided', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
referenceDayAsString: '2024-01-15',
firstDayOfTheWeek: 'SUNDAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY');
expect(mockDetectCalendarStartDay).not.toHaveBeenCalled();
});
it('should not append firstDayOfTheWeek when detected value is empty', () => {
mockDetectCalendarStartDay.mockReturnValue('' as any);
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 5,
unit: 'DAY',
timezone: 'America/New_York',
referenceDayAsString: '2024-01-15',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'PAST_5_DAY;;America/New_York;;2024-01-15;;',
);
});
});
describe('complex scenarios', () => {
it('should handle all optional fields together', () => {
const filter: RelativeDateFilter = {
direction: 'NEXT',
amount: 10,
unit: 'MONTH',
timezone: 'Europe/London',
referenceDayAsString: '2024-12-25',
firstDayOfTheWeek: 'MONDAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'NEXT_10_MONTH;;Europe/London;;2024-12-25;;MONDAY;;',
);
});
it('should handle THIS direction with all optional fields', () => {
const filter: RelativeDateFilter = {
direction: 'THIS',
amount: 7,
unit: 'WEEK',
timezone: 'Asia/Tokyo',
referenceDayAsString: '2024-06-01',
firstDayOfTheWeek: 'SUNDAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe(
'THIS_1_WEEK;;Asia/Tokyo;;2024-06-01;;SUNDAY;;',
);
});
it('should handle large amount values', () => {
const filter: RelativeDateFilter = {
direction: 'PAST',
amount: 999,
unit: 'DAY',
};
expect(stringifyRelativeDateFilter(filter)).toBe('PAST_999_DAY');
});
});
});

View file

@ -1,20 +0,0 @@
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
} from 'twenty-shared/types';
export const computeVariableDateViewFilterValue = (
direction: VariableDateViewFilterValueDirection,
amount: number | undefined,
unit: VariableDateViewFilterValueUnit,
) => {
if (direction === 'THIS') {
return `THIS_1_${unit}`;
} else if (amount === undefined || amount <= 0) {
throw new Error(
'Amount must be defined and greater than 0 for relative date filters',
);
}
return `${direction}_${amount.toString()}_${unit}`;
};

View file

@ -0,0 +1,37 @@
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils';
export const stringifyRelativeDateFilter = (
relativeDateFilter: RelativeDateFilter,
) => {
let relativeDateFilterStringified = `${relativeDateFilter.direction}_${relativeDateFilter.amount?.toString() ?? '1'}_${relativeDateFilter.unit}`;
if (relativeDateFilter.direction === 'THIS') {
relativeDateFilterStringified = `THIS_1_${relativeDateFilter.unit}`;
} else if (
!isDefined(relativeDateFilter.amount) ||
relativeDateFilter.amount <= 0
) {
throw new Error(
'Amount must be defined and greater than 0 for relative date filters',
);
}
if (isNonEmptyString(relativeDateFilter.timezone)) {
relativeDateFilterStringified = `${relativeDateFilterStringified};;${relativeDateFilter.timezone};;`;
if (isNonEmptyString(relativeDateFilter.referenceDayAsString)) {
relativeDateFilterStringified = `${relativeDateFilterStringified}${relativeDateFilter.referenceDayAsString};;`;
const firstDayOfTheWeek =
relativeDateFilter.firstDayOfTheWeek ?? detectCalendarStartDay();
if (isNonEmptyString(firstDayOfTheWeek)) {
relativeDateFilterStringified = `${relativeDateFilterStringified}${firstDayOfTheWeek};;`;
}
}
}
return relativeDateFilterStringified;
};

View file

@ -0,0 +1,126 @@
import { BODY_TYPES } from '../../constants/HttpRequest';
import { getBodyTypeFromHeaders } from '../getBodyTypeFromHeaders';
describe('getBodyTypeFromHeaders', () => {
describe('when headers is undefined or null', () => {
it('should return null for undefined headers', () => {
expect(getBodyTypeFromHeaders(undefined)).toBe(null);
});
it('should return null for empty headers object', () => {
expect(getBodyTypeFromHeaders({})).toBe(null);
});
});
describe('when content-type header is missing', () => {
it('should return null when content-type is not present', () => {
const headers = {
authorization: 'Bearer token',
accept: 'application/json',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
});
describe('when content-type header is present', () => {
it('should return rawJson for application/json content-type', () => {
const headers = {
'content-type': 'application/json',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.RAW_JSON);
});
it('should return formData for multipart/form-data content-type', () => {
const headers = {
'content-type': 'multipart/form-data',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.FORM_DATA);
});
it('should return keyValue for application/x-www-form-urlencoded content-type', () => {
const headers = {
'content-type': 'application/x-www-form-urlencoded',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.KEY_VALUE);
});
it('should return text for text/plain content-type', () => {
const headers = {
'content-type': 'text/plain',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.TEXT);
});
it('should return null for empty string content-type (none)', () => {
const headers = {
'content-type': '',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
it('should return null for unrecognized content-type', () => {
const headers = {
'content-type': 'application/xml',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
it('should return null for invalid content-type', () => {
const headers = {
'content-type': 'invalid/content-type',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
});
describe('case sensitivity', () => {
it('should handle lowercase content-type header key', () => {
const headers = {
'content-type': 'application/json',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.RAW_JSON);
});
it('should not match uppercase Content-Type header key', () => {
const headers = {
'Content-Type': 'application/json',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
});
describe('with multiple headers', () => {
it('should correctly identify body type when other headers are present', () => {
const headers = {
authorization: 'Bearer token',
'content-type': 'application/json',
accept: 'application/json',
'user-agent': 'Test Agent',
};
expect(getBodyTypeFromHeaders(headers)).toBe(BODY_TYPES.RAW_JSON);
});
});
describe('edge cases', () => {
it('should return null for content-type with charset parameter', () => {
const headers = {
'content-type': 'application/json; charset=utf-8',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
it('should return null for content-type with boundary parameter', () => {
const headers = {
'content-type': 'multipart/form-data; boundary=----WebKitFormBoundary',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
it('should return null for content-type with extra whitespace', () => {
const headers = {
'content-type': ' application/json ',
};
expect(getBodyTypeFromHeaders(headers)).toBe(null);
});
});
});

View file

@ -180,19 +180,30 @@ export const formatToHumanReadableDate = (date: Date | string) => {
return i18n.date(parsedJSDate, { dateStyle: 'medium' });
};
export const getDateFormatString = (
export const getDateTimeFormatStringFoDatePickerInputMask = (
dateFormat: DateFormat,
isDateTimeInput: boolean,
): string => {
const timePart = isDateTimeInput ? ' HH:mm' : '';
switch (dateFormat) {
case DateFormat.DAY_FIRST:
return `dd/MM/yyyy${timePart}`;
return `dd/MM/yyyy HH:mm`;
case DateFormat.YEAR_FIRST:
return `yyyy-MM-dd${timePart}`;
return `yyyy-MM-dd HH:mm`;
case DateFormat.MONTH_FIRST:
default:
return `MM/dd/yyyy${timePart}`;
return `MM/dd/yyyy HH:mm`;
}
};
export const getDateFormatStringForDatePickerInputMask = (
dateFormat: DateFormat,
): string => {
switch (dateFormat) {
case DateFormat.DAY_FIRST:
return `dd/MM/yyyy`;
case DateFormat.YEAR_FIRST:
return `yyyy-MM-dd`;
case DateFormat.MONTH_FIRST:
default:
return `MM/dd/yyyy`;
}
};

View file

@ -0,0 +1,19 @@
import { GraphQLISODateTime } from '@nestjs/graphql';
import { GraphQLInputObjectType, GraphQLList, GraphQLNonNull } from 'graphql';
import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type';
export const DateTimeFilterType = new GraphQLInputObjectType({
name: 'DateTimeFilter',
fields: {
eq: { type: GraphQLISODateTime },
gt: { type: GraphQLISODateTime },
gte: { type: GraphQLISODateTime },
in: { type: new GraphQLList(new GraphQLNonNull(GraphQLISODateTime)) },
lt: { type: GraphQLISODateTime },
lte: { type: GraphQLISODateTime },
neq: { type: GraphQLISODateTime },
is: { type: FilterIs },
},
});

View file

@ -3,20 +3,17 @@ import { Kind } from 'graphql/language';
export const DateScalarType = new GraphQLScalarType({
name: 'Date',
description: 'Date custom scalar type',
serialize(value: Date): number {
return value.getTime();
description:
"Date custom scalar type, as a string in format yyyy-MM-dd (ex: 2025-12-31), we don't signify time nor timezone for DATE.",
serialize(value: string): string {
return value;
},
parseValue(value: number): Date {
return new Date(value);
parseValue(value: string): string {
return value;
},
parseLiteral(ast): Date | null {
if (ast.kind === Kind.INT) {
return new Date(parseInt(ast.value, 10));
}
parseLiteral(ast): string | null {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
return ast.value;
}
return null;

View file

@ -10,6 +10,7 @@ import { UUIDScalarType } from './uuid.scalar';
export * from './big-float.scalar';
export * from './big-int.scalar';
export * from './cursor.scalar';
export * from './date.scalar';
export * from './position.scalar';
export * from './time.scalar';

View file

@ -41,6 +41,7 @@ import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-buil
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
import {
BigFloatScalarType,
DateScalarType,
TSVectorScalarType,
UUIDScalarType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ -77,7 +78,7 @@ export class TypeMapperService {
[FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.DATE, GraphQLISODateTime],
[FieldMetadataType.DATE, DateScalarType],
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
[
FieldMetadataType.NUMBER,
@ -119,7 +120,7 @@ export class TypeMapperService {
>([
[FieldMetadataType.UUID, UUIDFilterType],
[FieldMetadataType.TEXT, StringFilterType],
[FieldMetadataType.DATE_TIME, DateFilterType],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.DATE, DateFilterType],
[FieldMetadataType.BOOLEAN, BooleanFilterType],
[

View file

@ -1,13 +1,15 @@
import { Field, InputType, registerEnumType } from '@nestjs/graphql';
import {
Field,
GraphQLISODateTime,
InputType,
registerEnumType,
} from '@nestjs/graphql';
import { IsArray, IsOptional } from 'class-validator';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import {
DateScalarType,
UUIDScalarType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class ObjectRecordFilterInput implements Partial<ObjectRecordFilter> {
@ -29,14 +31,14 @@ export class ObjectRecordFilterInput implements Partial<ObjectRecordFilter> {
@IsOptional()
id?: UUIDFilterType | null;
@Field(() => DateFilterType, { nullable: true })
createdAt?: DateFilterType | null;
@Field(() => DateTimeFilterType, { nullable: true })
createdAt?: DateTimeFilterType | null;
@Field(() => DateFilterType, { nullable: true })
updatedAt?: DateFilterType | null;
@Field(() => DateTimeFilterType, { nullable: true })
updatedAt?: DateTimeFilterType | null;
@Field(() => DateFilterType, { nullable: true })
deletedAt?: DateFilterType | null;
@Field(() => DateTimeFilterType, { nullable: true })
deletedAt?: DateTimeFilterType | null;
}
@InputType('UUIDFilter')
@ -74,33 +76,33 @@ class UUIDFilterType {
is?: FilterIs;
}
@InputType('DateFilter')
class DateFilterType {
@Field(() => DateScalarType, { nullable: true })
@InputType('DateTimeFilter')
class DateTimeFilterType {
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
eq?: Date;
@Field(() => DateScalarType, { nullable: true })
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
gt?: Date;
@Field(() => DateScalarType, { nullable: true })
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
gte?: Date;
@Field(() => [DateScalarType], { nullable: true })
@Field(() => [GraphQLISODateTime], { nullable: true })
@IsOptional()
in?: Date[];
@Field(() => DateScalarType, { nullable: true })
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
lt?: Date;
@Field(() => DateScalarType, { nullable: true })
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
lte?: Date;
@Field(() => DateScalarType, { nullable: true })
@Field(() => GraphQLISODateTime, { nullable: true })
@IsOptional()
neq?: Date;

View file

@ -1,6 +1,5 @@
import { isPlainObject } from '@nestjs/common/utils/shared.utils';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@ -11,8 +10,7 @@ import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-module
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { isDate } from 'src/utils/date/isDate';
import { isValidDate } from 'src/utils/date/isValidDate';
export function formatResult<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any,
@ -115,55 +113,20 @@ export function formatResult<T>(
newData[parentField][compositeProperty.name] = value;
}
const dateFieldMetadataCollection = Object.values(
const fieldMetadataItemsOfTypeDateOnly = Object.values(
objectMetadataItemWithFieldMaps.fieldsById,
).filter((field) => field.type === FieldMetadataType.DATE);
// This is a temporary fix to handle a bug in the frontend where the date gets returned in the wrong timezone,
// thus returning the wrong date.
// In short, for example :
// - DB stores `2025-01-01`
// - TypeORM .returning() returns `2024-12-31T23:00:00.000Z`
// - we shift +1h (or whatever the timezone offset is on the server)
// - we return `2025-01-01T00:00:00.000Z`
// See this PR for more details: https://github.com/twentyhq/twenty/pull/9700
const serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift =
new Date().getTimezoneOffset() * 60 * 1000;
for (const dateFieldMetadata of dateFieldMetadataCollection) {
for (const dateField of fieldMetadataItemsOfTypeDateOnly) {
// @ts-expect-error legacy noImplicitAny
const rawUpdatedDate = newData[dateFieldMetadata.name] as
| string
| null
| undefined
| Date;
const rawUpdatedDate = newData[dateField.name] as string | null | undefined;
if (!isDefined(rawUpdatedDate)) {
continue;
}
if (isDate(rawUpdatedDate)) {
if (isValidDate(rawUpdatedDate)) {
const shiftedDate = new Date(
rawUpdatedDate.getTime() -
serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift,
);
// @ts-expect-error legacy noImplicitAny
newData[dateFieldMetadata.name] = shiftedDate;
}
} else if (isNonEmptyString(rawUpdatedDate)) {
// @ts-expect-error legacy noImplicitAny
const currentDate = new Date(newData[dateFieldMetadata.name]);
const shiftedDate = new Date(
new Date(currentDate).getTime() -
serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift,
);
// @ts-expect-error legacy noImplicitAny
newData[dateFieldMetadata.name] = shiftedDate;
}
// @ts-expect-error legacy noImplicitAny
newData[dateField.name] = rawUpdatedDate;
}
return newData as T;

View file

@ -8,7 +8,10 @@ import {
subWeeks,
subYears,
} from 'date-fns';
import { type VariableDateViewFilterValue } from 'twenty-shared/types';
import {
getPlainDateFromDate,
type RelativeDateFilter,
} from 'twenty-shared/utils';
import {
evaluateRelativeDateFilter,
@ -104,7 +107,7 @@ describe('Relative Date Filter Utils', () => {
describe('evaluateRelativeDateFilter', () => {
describe('NEXT direction', () => {
it('should return true for dates within the next N days', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'NEXT',
amount: 3,
unit: 'DAY',
@ -146,7 +149,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the next N weeks', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'NEXT',
amount: 2,
unit: 'WEEK',
@ -182,7 +185,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the next N months', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'NEXT',
amount: 2,
unit: 'MONTH',
@ -218,7 +221,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the next N years', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'NEXT',
amount: 1,
unit: 'YEAR',
@ -254,7 +257,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return false when amount is undefined', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'NEXT',
unit: 'DAY',
};
@ -270,7 +273,7 @@ describe('Relative Date Filter Utils', () => {
describe('PAST direction', () => {
it('should return true for dates within the past N days', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'PAST',
amount: 3,
unit: 'DAY',
@ -312,7 +315,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the past N weeks', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'PAST',
amount: 2,
unit: 'WEEK',
@ -348,7 +351,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the past N months', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'PAST',
amount: 2,
unit: 'MONTH',
@ -384,7 +387,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within the past N years', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'PAST',
amount: 1,
unit: 'YEAR',
@ -420,7 +423,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return false when amount is undefined', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'PAST',
unit: 'DAY',
};
@ -436,7 +439,7 @@ describe('Relative Date Filter Utils', () => {
describe('THIS direction', () => {
it('should return true for dates within this day', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'THIS',
unit: 'DAY',
};
@ -465,9 +468,11 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within this week', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'THIS',
unit: 'WEEK',
firstDayOfTheWeek: 'MONDAY',
referenceDayAsString: getPlainDateFromDate(now),
};
expect(
@ -476,12 +481,14 @@ describe('Relative Date Filter Utils', () => {
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-14T12:00:00Z'),
dateToCheck: new Date('2024-01-16T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(true);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-20T12:00:00Z'),
@ -498,14 +505,14 @@ describe('Relative Date Filter Utils', () => {
).toBe(false);
expect(
evaluateRelativeDateFilter({
dateToCheck: new Date('2024-01-21T12:00:00Z'),
dateToCheck: new Date('2024-01-22T12:00:00Z'),
relativeDateFilterValue,
}),
).toBe(false);
});
it('should return true for dates within this month', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'THIS',
unit: 'MONTH',
};
@ -546,7 +553,7 @@ describe('Relative Date Filter Utils', () => {
});
it('should return true for dates within this year', () => {
const relativeDateFilterValue: VariableDateViewFilterValue = {
const relativeDateFilterValue: RelativeDateFilter = {
direction: 'THIS',
unit: 'YEAR',
};

View file

@ -1,8 +1,5 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
addDays,
addMonths,
addWeeks,
addYears,
endOfDay,
endOfMonth,
endOfWeek,
@ -12,16 +9,15 @@ import {
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import {
type VariableDateViewFilterValue,
type VariableDateViewFilterValueUnit,
} from 'twenty-shared/types';
import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils';
addUnitToDateTime,
getFirstDayOfTheWeekAsANumberForDateFNS,
isDefined,
type RelativeDateFilter,
safeParseRelativeDateFilterJSONStringified,
subUnitFromDateTime,
} from 'twenty-shared/utils';
export const parseAndEvaluateRelativeDateFilter = ({
dateToCheck,
@ -31,7 +27,7 @@ export const parseAndEvaluateRelativeDateFilter = ({
relativeDateString: string;
}): boolean => {
const relativeDateFilterValue =
safeParseRelativeDateFilterValue(relativeDateString);
safeParseRelativeDateFilterJSONStringified(relativeDateString);
if (!relativeDateFilterValue) {
return false;
@ -48,7 +44,7 @@ export const evaluateRelativeDateFilter = ({
relativeDateFilterValue,
}: {
dateToCheck: Date;
relativeDateFilterValue: VariableDateViewFilterValue;
relativeDateFilterValue: RelativeDateFilter;
}): boolean => {
const now = new Date();
@ -66,16 +62,16 @@ export const evaluateRelativeDateFilter = ({
const evaluateNextDirection = (
dateToCheck: Date,
relativeDateFilterValue: VariableDateViewFilterValue,
relativeDateFilterValue: RelativeDateFilter,
now: Date,
): boolean => {
if (relativeDateFilterValue.amount === undefined) {
if (!isDefined(relativeDateFilterValue.amount)) {
return false;
}
const { amount, unit } = relativeDateFilterValue;
const endOfPeriod = addUnitToDate(now, amount, unit);
const endOfPeriod = addUnitToDateTime(now, amount, unit);
if (!endOfPeriod) {
return false;
@ -89,16 +85,16 @@ const evaluateNextDirection = (
function evaluatePastDirection(
dateToCheck: Date,
relativeDateFilterValue: VariableDateViewFilterValue,
relativeDateFilterValue: RelativeDateFilter,
now: Date,
): boolean {
if (relativeDateFilterValue.amount === undefined) {
if (!isDefined(relativeDateFilterValue.amount)) {
return false;
}
const { amount, unit } = relativeDateFilterValue;
const startOfPeriod = subtractUnitFromDate(now, amount, unit);
const startOfPeriod = subUnitFromDateTime(now, amount, unit);
if (!startOfPeriod) {
return false;
@ -112,11 +108,19 @@ function evaluatePastDirection(
function evaluateThisDirection(
dateToCheck: Date,
relativeDateValue: VariableDateViewFilterValue,
relativeDateValue: RelativeDateFilter,
now: Date,
): boolean {
const { unit } = relativeDateValue;
const firstDayOfTheWeekAsANumberForDateFNS = isNonEmptyString(
relativeDateValue.firstDayOfTheWeek,
)
? getFirstDayOfTheWeekAsANumberForDateFNS(
relativeDateValue.firstDayOfTheWeek,
)
: 1;
switch (unit) {
case 'DAY':
return isWithinInterval(dateToCheck, {
@ -125,8 +129,12 @@ function evaluateThisDirection(
});
case 'WEEK':
return isWithinInterval(dateToCheck, {
start: startOfWeek(now),
end: endOfWeek(now),
start: startOfWeek(now, {
weekStartsOn: firstDayOfTheWeekAsANumberForDateFNS,
}),
end: endOfWeek(now, {
weekStartsOn: firstDayOfTheWeekAsANumberForDateFNS,
}),
});
case 'MONTH':
return isWithinInterval(dateToCheck, {
@ -142,41 +150,3 @@ function evaluateThisDirection(
return false;
}
}
function addUnitToDate(
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
): Date | null {
switch (unit) {
case 'DAY':
return addDays(date, amount);
case 'WEEK':
return addWeeks(date, amount);
case 'MONTH':
return addMonths(date, amount);
case 'YEAR':
return addYears(date, amount);
default:
return null;
}
}
function subtractUnitFromDate(
date: Date,
amount: number,
unit: VariableDateViewFilterValueUnit,
): Date | null {
switch (unit) {
case 'DAY':
return subDays(date, amount);
case 'WEEK':
return subWeeks(date, amount);
case 'MONTH':
return subMonths(date, amount);
case 'YEAR':
return subYears(date, amount);
default:
return null;
}
}

View file

@ -127,7 +127,7 @@ export const failingFilterInputByFieldMetadataType: {
{
gqlFilterInput: { dateTimeField: { eq: 'not-a-date-time' } },
gqlErrorMessage:
'invalid input syntax for type timestamp with time zone: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
'invalid input syntax for type timestamp with time zone: "not-a-date-time"',
restFilterInput: 'dateTimeField[eq]:"not-a-date-time"',
restErrorMessage:
'invalid input syntax for type timestamp with time zone',
@ -135,7 +135,7 @@ export const failingFilterInputByFieldMetadataType: {
{
gqlFilterInput: { dateTimeField: { eq: {} } },
gqlErrorMessage:
'invalid input syntax for type timestamp with time zone: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
'invalid input syntax for type timestamp with time zone: "{}"',
restFilterInput: 'dateTimeField[eq]:"{}"',
restErrorMessage:
'invalid input syntax for type timestamp with time zone',
@ -143,7 +143,7 @@ export const failingFilterInputByFieldMetadataType: {
{
gqlFilterInput: { dateTimeField: { eq: [] } },
gqlErrorMessage:
'invalid input syntax for type timestamp with time zone: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
'invalid input syntax for type timestamp with time zone: "{}"',
restFilterInput: 'dateTimeField[eq]:"[]"',
restErrorMessage:
'invalid input syntax for type timestamp with time zone',
@ -158,22 +158,19 @@ export const failingFilterInputByFieldMetadataType: {
[FieldMetadataType.DATE]: [
{
gqlFilterInput: { dateField: { eq: 'not-a-date' } },
gqlErrorMessage:
'invalid input syntax for type date: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
gqlErrorMessage: 'invalid input syntax for type date: "not-a-date"',
restFilterInput: 'dateField[eq]:"{}"',
restErrorMessage: 'invalid input syntax for type date',
},
{
gqlFilterInput: { dateField: { eq: {} } },
gqlErrorMessage:
'invalid input syntax for type date: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
gqlErrorMessage: 'invalid input syntax for type date: "{}"',
restFilterInput: 'dateField[eq]:"{}"',
restErrorMessage: 'invalid input syntax for type date',
},
{
gqlFilterInput: { dateField: { eq: [] } },
gqlErrorMessage:
'invalid input syntax for type date: "0NaN-NaN-NaNTNaN:NaN:NaN.NaN+NaN:NaN"',
gqlErrorMessage: 'invalid input syntax for type date: "{}"',
restFilterInput: 'dateField[eq]:"[]"',
restErrorMessage: 'invalid input syntax for type date',
},

View file

@ -0,0 +1 @@
export const DATE_TYPE_FORMAT = 'yyyy-MM-dd';

View file

@ -0,0 +1,7 @@
import { type RelativeDateFilter } from '@/utils';
export const DEFAULT_RELATIVE_DATE_FILTER_VALUE: RelativeDateFilter = {
direction: 'THIS',
unit: 'DAY',
amount: undefined,
};

View file

@ -10,6 +10,8 @@
export { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from './CompositeFieldTypeSubFieldsNames';
export { CurrencyCode } from './CurrencyCode';
export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels';
export { DATE_TYPE_FORMAT } from './DateTypeFormat';
export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterValue';
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';

View file

@ -48,11 +48,6 @@ export type FloatFilter = {
is?: IsFilter;
};
/**
* Always use a DateFilter in the variables of a query, and never directly in the query.
*
* Because pg_graphql only works with ISO strings if it is passed to variables.
*/
export type DateFilter = {
eq?: string;
gt?: string;
@ -64,6 +59,17 @@ export type DateFilter = {
is?: IsFilter;
};
export type DateTimeFilter = {
eq?: string;
gt?: string;
gte?: string;
in?: string[];
lt?: string;
lte?: string;
neq?: string;
is?: IsFilter;
};
export type CurrencyFilter = {
amountMicros?: FloatFilter;
currencyCode?: SelectFilter;
@ -152,6 +158,7 @@ export type LeafFilter =
| StringFilter
| FloatFilter
| DateFilter
| DateTimeFilter
| CurrencyFilter
| URLFilter
| FullNameFilter

View file

@ -1,15 +0,0 @@
export type VariableDateViewFilterValueDirection = 'NEXT' | 'THIS' | 'PAST';
export type VariableDateViewFilterValueUnit = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
export type VariableDateViewFilterValue = {
direction: VariableDateViewFilterValueDirection;
amount?: number;
unit: VariableDateViewFilterValueUnit;
};
export const DEFAULT_RELATIVE_DATE_VALUE: VariableDateViewFilterValue = {
direction: 'THIS',
unit: 'DAY',
amount: undefined,
};

View file

@ -60,6 +60,7 @@ export type {
RatingFilter,
FloatFilter,
DateFilter,
DateTimeFilter,
CurrencyFilter,
URLFilter,
FullNameFilter,
@ -85,12 +86,6 @@ export type {
export type { RelationAndMorphRelationFieldMetadataType } from './RelationAndMorphRelationFieldMetadataType';
export type { RelationCreationPayload } from './RelationCreationPayload';
export { RelationType } from './RelationType';
export type {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
VariableDateViewFilterValue,
} from './RelativeDateValue';
export { DEFAULT_RELATIVE_DATE_VALUE } from './RelativeDateValue';
export type { RestrictedFieldPermissions } from './RestrictedFieldPermissions';
export type { RestrictedFieldsPermissions } from './RestrictedFieldsPermissions';
export { SettingsPath } from './SettingsPath';

View file

@ -1,6 +1,6 @@
import { safeParseRelativeDateFilterValue } from '../safeParseRelativeDateFilterValue';
import { safeParseRelativeDateFilterJSONStringified } from '@/utils/safeParseRelativeDateFilterJSONStringified';
describe('safeParseRelativeDateFilterValue', () => {
describe('safeParseRelativeDateFilterJSONStringified', () => {
describe('valid inputs', () => {
describe('NEXT direction', () => {
it('should parse NEXT direction with DAY unit', () => {
@ -10,7 +10,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
@ -26,7 +26,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'WEEK',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
@ -42,7 +42,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'MONTH',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
@ -58,7 +58,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'YEAR',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
@ -76,7 +76,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'PAST',
@ -92,7 +92,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'WEEK',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'PAST',
@ -108,7 +108,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'MONTH',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'PAST',
@ -124,7 +124,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'YEAR',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'PAST',
@ -141,7 +141,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
@ -155,7 +155,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'WEEK',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
@ -169,7 +169,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'MONTH',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
@ -183,7 +183,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'YEAR',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
@ -198,7 +198,7 @@ describe('safeParseRelativeDateFilterValue', () => {
amount: undefined,
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
@ -211,17 +211,20 @@ describe('safeParseRelativeDateFilterValue', () => {
describe('invalid inputs', () => {
describe('JSON parsing errors', () => {
it('should return undefined for invalid JSON', () => {
const result = safeParseRelativeDateFilterValue('invalid json');
const result =
safeParseRelativeDateFilterJSONStringified('invalid json');
expect(result).toBeUndefined();
});
it('should return undefined for empty string', () => {
const result = safeParseRelativeDateFilterValue('');
const result = safeParseRelativeDateFilterJSONStringified('');
expect(result).toBeUndefined();
});
it('should return undefined for unclosed JSON', () => {
const result = safeParseRelativeDateFilterValue('{"direction": "NEXT"');
const result = safeParseRelativeDateFilterJSONStringified(
'{"direction": "NEXT"',
);
expect(result).toBeUndefined();
});
});
@ -233,7 +236,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -243,7 +246,7 @@ describe('safeParseRelativeDateFilterValue', () => {
amount: 1,
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -254,7 +257,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -265,7 +268,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'HOUR',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -275,7 +278,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -285,7 +288,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -296,7 +299,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -307,43 +310,32 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
expect(result).toBeUndefined();
});
it('should return undefined for NEXT direction with string amount', () => {
const input = JSON.stringify({
direction: 'NEXT',
amount: '1',
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
it('should return undefined for non-object input', () => {
const result = safeParseRelativeDateFilterValue('"string"');
const result = safeParseRelativeDateFilterJSONStringified('"string"');
expect(result).toBeUndefined();
});
it('should return undefined for array input', () => {
const result = safeParseRelativeDateFilterValue('[1, 2, 3]');
const result = safeParseRelativeDateFilterJSONStringified('[1, 2, 3]');
expect(result).toBeUndefined();
});
it('should return undefined for null input', () => {
const result = safeParseRelativeDateFilterValue('null');
const result = safeParseRelativeDateFilterJSONStringified('null');
expect(result).toBeUndefined();
});
it('should return undefined for boolean input', () => {
const result = safeParseRelativeDateFilterValue('true');
const result = safeParseRelativeDateFilterJSONStringified('true');
expect(result).toBeUndefined();
});
it('should return undefined for number input', () => {
const result = safeParseRelativeDateFilterValue('123');
const result = safeParseRelativeDateFilterJSONStringified('123');
expect(result).toBeUndefined();
});
});
@ -356,12 +348,8 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
expect(result).toEqual({
direction: 'NEXT',
amount: 1.5,
unit: 'DAY',
});
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
it('should return undefined for object with extra properties', () => {
@ -372,7 +360,7 @@ describe('safeParseRelativeDateFilterValue', () => {
extraProperty: 'should be ignored',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
amount: 1,
@ -383,7 +371,7 @@ describe('safeParseRelativeDateFilterValue', () => {
it('should return undefined for empty object', () => {
const input = JSON.stringify({});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toBeUndefined();
});
@ -395,7 +383,7 @@ describe('safeParseRelativeDateFilterValue', () => {
});
// THIS direction should work with amount present, as the schema allows it
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'THIS',
amount: 1,
@ -410,7 +398,7 @@ describe('safeParseRelativeDateFilterValue', () => {
unit: 'DAY',
});
const result = safeParseRelativeDateFilterValue(input);
const result = safeParseRelativeDateFilterJSONStringified(input);
expect(result).toEqual({
direction: 'NEXT',
amount: 999999,

View file

@ -0,0 +1,19 @@
import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema';
import { addDays, addMonths, addWeeks, addYears } from 'date-fns';
export const addUnitToDateTime = (
dateTime: Date,
amount: number,
unit: RelativeDateFilterUnit,
) => {
switch (unit) {
case 'DAY':
return addDays(dateTime, amount);
case 'WEEK':
return addWeeks(dateTime, amount);
case 'MONTH':
return addMonths(dateTime, amount);
case 'YEAR':
return addYears(dateTime, amount);
}
};

View file

@ -0,0 +1,5 @@
import z from 'zod';
export const firstDayOfWeekSchema = z.enum(['MONDAY', 'SATURDAY', 'SUNDAY']);
export type FirstDayOfTheWeek = z.infer<typeof firstDayOfWeekSchema>;

View file

@ -0,0 +1,6 @@
import { DATE_TYPE_FORMAT } from '@/constants';
import { parse } from 'date-fns';
export const getDateFromPlainDate = (plainDate: string) => {
return parse(plainDate, DATE_TYPE_FORMAT, new Date());
};

View file

@ -0,0 +1,33 @@
import { type Nullable } from '@/types';
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS';
import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema';
import { isDefined } from '@/utils/validation';
import { endOfDay, endOfMonth, endOfWeek, endOfYear } from 'date-fns';
export const getEndUnitOfDateTime = (
dateTime: Date,
unit: RelativeDateFilterUnit,
firstDayOfTheWeek?: Nullable<FirstDayOfTheWeek>,
) => {
switch (unit) {
case 'DAY':
return endOfDay(dateTime);
case 'WEEK': {
if (isDefined(firstDayOfTheWeek)) {
const firstDayOfTheWeekAsDateFNSNumber =
getFirstDayOfTheWeekAsANumberForDateFNS(firstDayOfTheWeek);
return endOfWeek(dateTime, {
weekStartsOn: firstDayOfTheWeekAsDateFNSNumber,
});
} else {
return endOfWeek(dateTime);
}
}
case 'MONTH':
return endOfMonth(dateTime);
case 'YEAR':
return endOfYear(dateTime);
}
};

View file

@ -0,0 +1,17 @@
import { assertUnreachable } from '@/utils/assertUnreachable';
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
export const getFirstDayOfTheWeekAsANumberForDateFNS = (
firstDayOfTheWeek: FirstDayOfTheWeek,
): 0 | 1 | 6 => {
switch (firstDayOfTheWeek) {
case 'MONDAY':
return 1;
case 'SATURDAY':
return 6;
case 'SUNDAY':
return 0;
default:
return assertUnreachable(firstDayOfTheWeek);
}
};

View file

@ -0,0 +1,6 @@
import { DATE_TYPE_FORMAT } from '@/constants';
import { format } from 'date-fns';
export const getPlainDateFromDate = (date: Date) => {
return format(date, DATE_TYPE_FORMAT);
};

Some files were not shown because too many files have changed in this diff Show more