Feat: add support for day-first and year-first date formats (DD/MM/YYYY, YYYY/MM/DD) (#12333)

Closes #12152 



https://github.com/user-attachments/assets/53640777-578f-4de8-a1f8-52d409a7582d

---------

Co-authored-by: etiennejouan <jouan.etienne@gmail.com>
This commit is contained in:
Abdul Rahman
2025-06-03 17:42:01 +05:30
committed by GitHub
parent 70cc3e75fe
commit 278a7baf5e
14 changed files with 125 additions and 114 deletions

View File

@ -11,23 +11,14 @@ import {
} from '@/ui/input/components/internal/date/components/InternalDatePicker';
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 { parseStringToDate } from '@/ui/input/components/internal/date/utils/parseStringToDate';
import { useDateParser } from '@/ui/input/components/internal/hooks/useDateParser';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { UserContext } from '@/users/contexts/UserContext';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import {
ChangeEvent,
KeyboardEvent,
useContext,
useId,
useRef,
useState,
} from 'react';
import { ChangeEvent, KeyboardEvent, useId, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
import { Nullable } from 'twenty-ui/utilities';
@ -93,7 +84,9 @@ export const FormDateTimeFieldInput = ({
readonly,
placeholder,
}: FormDateTimeFieldInputProps) => {
const { timeZone } = useContext(UserContext);
const { parseToString, parseToDate } = useDateParser({
isDateTimeInput: !dateOnly,
});
const inputId = useId();
@ -121,11 +114,7 @@ export const FormDateTimeFieldInput = ({
const [inputDateTime, setInputDateTime] = useState(
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parseDateToString({
date: draftValueAsDate,
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
? parseToString(draftValueAsDate)
: '',
);
@ -172,15 +161,7 @@ export const FormDateTimeFieldInput = ({
value: newDate?.toDateString() ?? null,
});
setInputDateTime(
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
: '',
);
setInputDateTime(isDefined(newDate) ? parseToString(newDate) : '');
setPickerDate(newDate);
@ -230,15 +211,7 @@ export const FormDateTimeFieldInput = ({
setPickerDate(newDate);
setInputDateTime(
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
})
: '',
);
setInputDateTime(isDefined(newDate) ? parseToString(newDate) : '');
persistDate(newDate);
};
@ -264,15 +237,10 @@ export const FormDateTimeFieldInput = ({
if (inputDateTimeTrimmed === '') {
handlePickerClear();
return;
}
const parsedInputDateTime = parseStringToDate({
dateAsString: inputDateTimeTrimmed,
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
});
const parsedInputDateTime = parseToDate(inputDateTimeTrimmed);
if (!isDefined(parsedInputDateTime)) {
return;
@ -293,13 +261,7 @@ export const FormDateTimeFieldInput = ({
setPickerDate(validatedDate);
setInputDateTime(
parseDateToString({
date: validatedDate,
isDateTimeInput: !dateOnly,
userTimezone: timeZone,
}),
);
setInputDateTime(parseToString(validatedDate));
persistDate(validatedDate);
};

View File

@ -5,12 +5,12 @@ import { Select } from '@/ui/input/components/Select';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
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 './InternalDatePicker';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
@ -38,7 +38,6 @@ type AbsoluteDatePickerHeaderProps = {
prevMonthButtonDisabled: boolean;
nextMonthButtonDisabled: boolean;
isDateTimeInput?: boolean;
timeZone: string;
hideInput?: boolean;
};
@ -52,7 +51,6 @@ export const AbsoluteDatePickerHeader = ({
prevMonthButtonDisabled,
nextMonthButtonDisabled,
isDateTimeInput,
timeZone,
hideInput = false,
}: AbsoluteDatePickerHeaderProps) => {
const endOfDayDateTimeInLocalTimezone = DateTime.now().set({
@ -74,7 +72,6 @@ export const AbsoluteDatePickerHeader = ({
date={date}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
)}

View File

@ -1,18 +1,19 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useIMask } from 'react-imask';
import { useRecoilValue } from 'recoil';
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
import { DATE_BLOCKS } from '@/ui/input/components/internal/date/constants/DateBlocks';
import { DATE_MASK } from '@/ui/input/components/internal/date/constants/DateMask';
import { DATE_TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/DateTimeBlocks';
import { DATE_TIME_MASK } from '@/ui/input/components/internal/date/constants/DateTimeMask';
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 { parseStringToDate } from '@/ui/input/components/internal/date/utils/parseStringToDate';
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 { isDefined } from 'twenty-shared/utils';
import { useDateParser } from '../../hooks/useDateParser';
const StyledInputContainer = styled.div`
align-items: center;
@ -44,42 +45,30 @@ type DateTimeInputProps = {
onChange?: (date: Date | null) => void;
date: Date | null;
isDateTimeInput?: boolean;
userTimezone?: string;
onError?: (error: Error) => void;
};
export const DateTimeInput = ({
date,
onChange,
isDateTimeInput,
userTimezone,
}: DateTimeInputProps) => {
const [hasError, setHasError] = useState(false);
const handleParseDateToString = useCallback(
(date: any) => {
return parseDateToString({
date,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
},
[isDateTimeInput, userTimezone],
);
const { dateFormat } = useRecoilValue(dateTimeFormatState);
const { parseToString, parseToDate } = useDateParser({
isDateTimeInput: isDateTimeInput === true,
});
const handleParseStringToDate = (str: string) => {
const date = parseStringToDate({
dateAsString: str,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
const date = parseToDate(str);
setHasError(isNull(date) === true);
return date;
};
const pattern = isDateTimeInput ? DATE_TIME_MASK : DATE_MASK;
const pattern = isDateTimeInput
? getDateTimeMask(dateFormat)
: getDateMask(dateFormat);
const blocks = isDateTimeInput ? DATE_TIME_BLOCKS : DATE_BLOCKS;
const { ref, setValue, value } = useIMask(
@ -89,18 +78,14 @@ export const DateTimeInput = ({
blocks,
min: MIN_DATE,
max: MAX_DATE,
format: handleParseDateToString,
format: (date: any) => parseToString(date),
parse: handleParseStringToDate,
lazy: false,
autofix: true,
},
{
onComplete: (value) => {
const parsedDate = parseStringToDate({
dateAsString: value,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
const parsedDate = parseToDate(value);
onChange?.(parsedDate);
},
@ -115,23 +100,14 @@ export const DateTimeInput = ({
return;
}
setValue(
parseDateToString({
date: date,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
}),
);
}, [date, setValue, isDateTimeInput, userTimezone]);
setValue(parseToString(date));
}, [date, setValue, parseToString]);
return (
<StyledInputContainer>
<StyledInput
type="text"
ref={ref as any}
placeholder={`Type date${
isDateTimeInput ? ' and time' : ' (mm/dd/yyyy)'
}`}
value={value}
onChange={() => {}} // Prevent React warning
hasError={hasError}

View File

@ -511,7 +511,6 @@ export const DateTimePicker = ({
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
}
renderCustomHeader={({
@ -536,7 +535,6 @@ export const DateTimePicker = ({
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput}
timeZone={timeZone}
hideInput={hideHeaderInput}
/>
)

View File

@ -1 +0,0 @@
export const DATE_MASK = 'm`/d`/Y`'; // See https://imask.js.org/guide.html#masked-date

View File

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

View File

@ -1,3 +0,0 @@
import { TIME_MASK } from '@/ui/input/components/internal/date/constants/TimeMask';
export const DATE_TIME_MASK = `m\`/d\`/Y\` ${TIME_MASK}`;

View File

@ -1 +0,0 @@
export const DATE_TIME_PARSER_FORMAT = 'MM/dd/yyyy HH:mm';

View File

@ -0,0 +1,13 @@
import { DateFormat } from '~/modules/localization/constants/DateFormat';
export const getDateMask = (dateFormat: DateFormat): string => {
switch (dateFormat) {
case DateFormat.DAY_FIRST:
return 'd`/m`/Y`';
case DateFormat.YEAR_FIRST:
return 'Y`-m`-d`';
case DateFormat.MONTH_FIRST:
default:
return 'm`/d`/Y`';
}
};

View File

@ -0,0 +1,8 @@
import { TIME_MASK } from '@/ui/input/components/internal/date/constants/TimeMask';
import { DateFormat } from '@/localization/constants/DateFormat';
import { getDateMask } from './getDateMask';
export const getDateTimeMask = (dateFormat: DateFormat): string => {
return `${getDateMask(dateFormat)} ${TIME_MASK}`;
};

View File

@ -1,21 +1,21 @@
import { DATE_PARSER_FORMAT } from '@/ui/input/components/internal/date/constants/DateParserFormat';
import { DATE_TIME_PARSER_FORMAT } from '@/ui/input/components/internal/date/constants/DateTimeParserFormat';
import { DateFormat } from '@/localization/constants/DateFormat';
import { DateTime } from 'luxon';
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 = isDateTimeInput
? DATE_TIME_PARSER_FORMAT
: DATE_PARSER_FORMAT;
const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput);
const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone });

View File

@ -1,21 +1,21 @@
import { DATE_PARSER_FORMAT } from '@/ui/input/components/internal/date/constants/DateParserFormat';
import { DATE_TIME_PARSER_FORMAT } from '@/ui/input/components/internal/date/constants/DateTimeParserFormat';
import { DateFormat } from '@/localization/constants/DateFormat';
import { DateTime } from 'luxon';
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 = isDateTimeInput
? DATE_TIME_PARSER_FORMAT
: DATE_PARSER_FORMAT;
const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput);
const parsedDate = isDateTimeInput
? DateTime.fromFormat(dateAsString, parsingFormat, { zone: userTimezone })

View File

@ -0,0 +1,44 @@
import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState';
import { UserContext } from '@/users/contexts/UserContext';
import { useCallback, useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { parseDateToString } from '../date/utils/parseDateToString';
import { parseStringToDate } from '../date/utils/parseStringToDate';
type UseDateParserProps = {
isDateTimeInput: boolean;
};
export const useDateParser = ({ isDateTimeInput }: UseDateParserProps) => {
const { dateFormat } = useRecoilValue(dateTimeFormatState);
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,9 +4,11 @@ import { differenceInCalendarDays, formatDistanceToNow } from 'date-fns';
import { DateTime } from 'luxon';
import moize from 'moize';
import { logError } from './logError';
import { DateFormat } from '@/localization/constants/DateFormat';
import { isDefined } from 'twenty-shared/utils';
import { logError } from './logError';
export const DEFAULT_DATE_LOCALE = 'en-EN';
export const parseDate = (dateToParse: Date | string | number) => {
@ -212,3 +214,20 @@ export const formatToHumanReadableDateTime = (date: Date | string) => {
minute: 'numeric',
}).format(parsedJSDate);
};
export const getDateFormatString = (
dateFormat: DateFormat,
isDateTimeInput: boolean,
): string => {
const timePart = isDateTimeInput ? ' HH:mm' : '';
switch (dateFormat) {
case DateFormat.DAY_FIRST:
return `dd/MM/yyyy${timePart}`;
case DateFormat.YEAR_FIRST:
return `yyyy-MM-dd${timePart}`;
case DateFormat.MONTH_FIRST:
default:
return `MM/dd/yyyy${timePart}`;
}
};