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:
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const DATE_MASK = 'm`/d`/Y`'; // See https://imask.js.org/guide.html#masked-date
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const MAX_DATE = new Date(2100, 11, 31);
|
||||
@ -0,0 +1 @@
|
||||
export const MIN_DATE = new Date(1900, 0, 1);
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user