Date picker for Date and DateTime field input (#4981)

- Implemented correct mask for Date and DateTime field in
InternalDatePicker
- Use only keyDown event and click outside in InternalDatePicker and
DateInput
- Refactored InternalDatePicker UI to have month and year displayed
- Fixed bug and synchronized date value between the different inputs
that can change it

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>
This commit is contained in:
Lucas Bordeau
2024-04-16 16:58:08 +02:00
committed by GitHub
parent d63937ec6f
commit 19a3be7b1b
22 changed files with 261 additions and 122 deletions

View File

@ -1,10 +1,14 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useIMask } from 'react-imask';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
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';
const StyledInputContainer = styled.div`
width: 100%;
@ -37,20 +41,27 @@ export const DateTimeInput = ({
onChange,
isDateTimeInput,
}: DateTimeInputProps) => {
const parsingFormat = isDateTimeInput ? 'MM/dd/yyyy HH:mm' : 'MM/dd/yyyy';
const [hasError, setHasError] = useState(false);
const parseDateToString = (date: any) => {
const dateParsed = DateTime.fromJSDate(date);
const parseDateToString = useCallback(
(date: any) => {
const dateParsed = DateTime.fromJSDate(date);
const formattedDate = dateParsed.toFormat('MM/dd/yyyy HH:mm');
const formattedDate = dateParsed.toFormat(parsingFormat);
return formattedDate;
};
return formattedDate;
},
[parsingFormat],
);
const parseStringToDate = (str: string) => {
setHasError(false);
const parsedDate = DateTime.fromFormat(str, 'MM/dd/yyyy HH:mm');
const parsedDate = isDateTimeInput
? DateTime.fromFormat(str, parsingFormat)
: DateTime.fromFormat(str, parsingFormat, { zone: 'utc' });
const isValid = parsedDate.isValid;
@ -65,13 +76,16 @@ export const DateTimeInput = ({
return jsDate;
};
const pattern = isDateTimeInput ? DATE_TIME_MASK : DATE_MASK;
const blocks = isDateTimeInput ? DATE_TIME_BLOCKS : DATE_BLOCKS;
const { ref, setValue, value } = useIMask(
{
mask: Date,
pattern: DATE_TIME_MASK,
blocks: DATE_TIME_BLOCKS,
min: new Date(1970, 0, 1),
max: new Date(2100, 0, 1),
pattern,
blocks,
min: MIN_DATE,
max: MAX_DATE,
format: parseDateToString,
parse: parseStringToDate,
lazy: false,
@ -91,7 +105,7 @@ export const DateTimeInput = ({
useEffect(() => {
setValue(parseDateToString(date));
}, [date, setValue]);
}, [date, setValue, parseDateToString]);
return (
<StyledInputContainer>
@ -102,6 +116,7 @@ export const DateTimeInput = ({
isDateTimeInput ? ' and time' : ' (mm/dd/yyyy)'
}`}
value={value}
onChange={() => {}} // Prevent React warning
hasError={hasError}
/>
</StyledInputContainer>

View File

@ -1,6 +1,7 @@
import ReactDatePicker from 'react-datepicker';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { Key } from 'ts-key-enum';
import { IconCalendarX, IconChevronLeft, IconChevronRight } from 'twenty-ui';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
@ -16,6 +17,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/components/MenuItemLeftContent';
import { StyledHoverableMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase';
import { OVERLAY_BACKGROUND } from '@/ui/theme/constants/OverlayBackground';
import { isDefined } from '~/utils/isDefined';
import 'react-datepicker/dist/react-datepicker.css';
@ -37,6 +39,10 @@ const StyledContainer = styled.div`
padding: 0 !important;
}
& .react-datepicker__triangle {
display: none;
}
& .react-datepicker__triangle::after {
display: none;
}
@ -259,7 +265,7 @@ const StyledButton = styled(MenuItemLeftContent)`
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
justify-content: flex-end;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
@ -267,29 +273,40 @@ const StyledCustomDatePickerHeader = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledMonthText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
padding: ${({ theme }) => theme.spacing(2)};
`;
export type InternalDatePickerProps = {
date: Date;
onMouseSelect?: (date: Date | null) => void;
onChange?: (date: Date | null) => void;
clearable?: boolean;
isDateTimeInput?: boolean;
onClickOutside?: (event: MouseEvent | TouchEvent, date: Date | null) => void;
onEnter?: (date: Date | null) => void;
onEscape?: (date: Date | null) => void;
keyboardEventsDisabled?: boolean;
onClear?: () => void;
};
const PICKER_DATE_FORMAT = 'MM/dd/yyyy';
export const InternalDatePicker = ({
date,
onChange,
onMouseSelect,
onEnter,
onEscape,
clearable = true,
isDateTimeInput,
onClickOutside,
keyboardEventsDisabled,
onClear,
}: InternalDatePickerProps) => {
const internalDate = date ?? new Date();
const dateFormatted =
DateTime.fromJSDate(internalDate).toFormat(PICKER_DATE_FORMAT);
const monthLabel = DateTime.fromJSDate(internalDate).toFormat('LLLL');
const yearLabel = DateTime.fromJSDate(internalDate).toFormat('yyyy');
const { closeDropdown } = useDropdown(MONTH_AND_YEAR_DROPDOWN_ID);
const { closeDropdown: closeDropdownMonthSelect } = useDropdown(
@ -301,7 +318,7 @@ export const InternalDatePicker = ({
const handleClear = () => {
closeDropdowns();
onMouseSelect?.(null);
onClear?.();
};
const closeDropdowns = () => {
@ -310,28 +327,59 @@ export const InternalDatePicker = ({
closeDropdown();
};
const handleClickOutside = (event: any) => {
closeDropdowns();
onClickOutside?.(event, internalDate);
};
const handleMouseSelect = (newDate: Date) => {
closeDropdowns();
onMouseSelect?.(newDate);
};
// TODO: implement keyboard events here
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isDefined(keyboardEventsDisabled) && keyboardEventsDisabled) {
return;
}
switch (event.key) {
case Key.Enter: {
event.stopPropagation();
event.preventDefault();
closeDropdowns();
onEnter?.(internalDate);
break;
}
case Key.Escape: {
event.stopPropagation();
event.preventDefault();
closeDropdowns();
onEscape?.(internalDate);
break;
}
}
};
return (
<StyledContainer>
<StyledContainer onKeyDown={handleKeyDown}>
<div className={clearable ? 'clearable ' : ''}>
<ReactDatePicker
open={true}
selected={internalDate}
value={dateFormatted}
openToDate={internalDate}
onChange={(newDate) => {
onChange?.(newDate);
}}
customInput={
<DateTimeInput
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
/>
}
onMonthChange={(newDate) => {
onChange?.(newDate);
}}
onYearChange={(newDate) => {
onChange?.(newDate);
}}
renderCustomHeader={({
decreaseMonth,
increaseMonth,
@ -345,7 +393,9 @@ export const InternalDatePicker = ({
onChange={onChange}
/>
<StyledCustomDatePickerHeader>
<TimeInput date={internalDate} onChange={onChange} />
{isDateTimeInput && (
<TimeInput date={internalDate} onChange={onChange} />
)}
<MonthAndYearDropdown date={internalDate} onChange={onChange} />
<LightIconButton
Icon={IconChevronLeft}
@ -360,9 +410,11 @@ export const InternalDatePicker = ({
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
<StyledMonthText>
{monthLabel} - {yearLabel}
</StyledMonthText>
</>
)}
customInput={<></>}
onSelect={(date: Date, event) => {
const dateUTC = DateTime.fromJSDate(date, {
zone: 'utc',
@ -374,8 +426,7 @@ export const InternalDatePicker = ({
onChange?.(dateUTC);
}
}}
onClickOutside={handleClickOutside}
></ReactDatePicker>
/>
</div>
{clearable && (
<StyledButtonContainer onClick={handleClear} isMenuOpen={false}>

View File

@ -1,6 +1,5 @@
import { IconCalendarDue } from 'twenty-ui';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Select } from '@/ui/input/components/Select';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@ -57,7 +56,7 @@ export const MonthAndYearDropdown = ({
<Dropdown
dropdownId={MONTH_AND_YEAR_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: TableHotkeyScope.CellEditMode,
scope: MONTH_AND_YEAR_DROPDOWN_ID,
}}
dropdownPlacement="bottom-start"
clickableComponent={

View File

@ -0,0 +1,22 @@
import { IMask } from 'react-imask';
import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate';
import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate';
export const DATE_BLOCKS = {
YYYY: {
mask: IMask.MaskedRange,
from: MIN_DATE.getFullYear(),
to: MAX_DATE.getFullYear(),
},
MM: {
mask: IMask.MaskedRange,
from: 1,
to: 12,
},
DD: {
mask: IMask.MaskedRange,
from: 1,
to: 31,
},
};

View File

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

View File

@ -1,22 +1,7 @@
import { IMask } from 'react-imask';
import { DATE_BLOCKS } from '@/ui/input/components/internal/date/constants/DateBlocks';
import { TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/TimeBlocks';
export const DATE_TIME_BLOCKS = {
YYYY: {
mask: IMask.MaskedRange,
from: 1970,
to: 2100,
},
MM: {
mask: IMask.MaskedRange,
from: 1,
to: 12,
},
DD: {
mask: IMask.MaskedRange,
from: 1,
to: 31,
},
...DATE_BLOCKS,
...TIME_BLOCKS,
};

View File

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

View File

@ -0,0 +1 @@
export const MAX_DATE = new Date(2100, 11, 31);

View File

@ -0,0 +1 @@
export const MIN_DATE = new Date(1900, 0, 1);

View File

@ -9,6 +9,6 @@ export const TIME_BLOCKS = {
mm: {
mask: IMask.MaskedRange, // Use MaskedRange for valid minute range (0-59)
from: 0,
to: 61,
to: 59,
},
};

View File

@ -1 +1 @@
export const TIME_MASK = 'HH:mm'; // Define blocks for hours and minutes
export const TIME_MASK = 'HH`:mm`'; // Define blocks for hours and minutes