New Datetime field picker (#4907)

### Description
New Datetime field picker

### Refs
https://github.com/twentyhq/twenty/issues/4376

### Demo


https://github.com/twentyhq/twenty/assets/140154534/32656323-972c-413a-9986-a78efffae1b4


Fixes #4376

---------

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>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
gitstart-app[bot] 2024-04-13 19:07:51 +02:00 committed by GitHub
parent 464a2d5998
commit efcb5dc6d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 468 additions and 98 deletions

View File

@ -152,6 +152,7 @@
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.4",
"react-icons": "^4.12.0",
"react-imask": "^7.6.0",
"react-intersection-observer": "^9.5.2",
"react-loading-skeleton": "^3.3.1",
"react-phone-number-input": "^3.3.4",

View File

@ -6,8 +6,6 @@ import SearchBar from '@theme-original/SearchBar';
const CustomComponents = {
'search-bar': () => {
const openSearchModal = () => {
console.log('yo');
const searchInput = document.querySelector('#search-bar');
if (searchInput) {
searchInput.focus();

View File

@ -1,5 +1,6 @@
import { IconBell } from "@tabler/icons-react";
import { MenuItemToggle } from "@/ui/navigation/menu-item/components/MenuItemToggle";
import { IconBell } from '@tabler/icons-react';
import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle';
export const MyComponent = () => {
const handleToggleChange = (toggled) => {

View File

@ -1,5 +1,6 @@
import {
IconCalendarEvent,
IconCalendarTime,
IconCheck,
IconCoins,
IconComponent,
@ -67,8 +68,8 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
defaultValue: true,
},
[FieldMetadataType.DateTime]: {
label: 'Date & Time',
Icon: IconCalendarEvent,
label: 'Date and Time',
Icon: IconCalendarTime,
defaultValue: DEFAULT_DATE_VALUE.toISOString(),
},
[FieldMetadataType.Date]: {

View File

@ -41,9 +41,9 @@ export type DateInputProps = {
export const DateInput = ({
value,
hotkeyScope,
onEnter,
onEscape,
hotkeyScope,
onClickOutside,
clearable,
onChange,
@ -65,7 +65,7 @@ export const DateInput = ({
],
});
const handleChange = (newDate: Date) => {
const handleChange = (newDate: Date | null) => {
setInternalValue(newDate);
onChange?.(newDate);
};
@ -96,6 +96,7 @@ export const DateInput = ({
}}
clearable={clearable ? clearable : false}
isDateTimeInput={isDateTimeInput}
onClickOutside={onClickOutside}
/>
</StyledCalendarContainer>
</div>

View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { useIMask } from 'react-imask';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { DATE_TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/DateTimeBlocks';
import { DATE_TIME_MASK } from '@/ui/input/components/internal/date/constants/DateTimeMask';
const StyledInputContainer = styled.div`
width: 100%;
display: flex;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: ${({ theme }) => theme.spacing(8)};
`;
const StyledInput = styled.input<{ hasError?: boolean }>`
background: ${({ theme }) => theme.background.secondary};
border: none;
color: ${({ theme }) => theme.font.color.primary};
outline: none;
padding: 8px;
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
width: 100%;
color: ${({ hasError, theme }) => (hasError ? theme.color.red : 'inherit')};
`;
type DateTimeInputProps = {
onChange?: (date: Date | null) => void;
date: Date | null;
isDateTimeInput?: boolean;
onError?: (error: Error) => void;
};
export const DateTimeInput = ({
date,
onChange,
isDateTimeInput,
}: DateTimeInputProps) => {
const [hasError, setHasError] = useState(false);
const parseDateToString = (date: any) => {
const dateParsed = DateTime.fromJSDate(date);
const formattedDate = dateParsed.toFormat('MM/dd/yyyy HH:mm');
return formattedDate;
};
const parseStringToDate = (str: string) => {
setHasError(false);
const parsedDate = DateTime.fromFormat(str, 'MM/dd/yyyy HH:mm');
const isValid = parsedDate.isValid;
if (!isValid) {
setHasError(true);
return null;
}
const jsDate = parsedDate.toJSDate();
return jsDate;
};
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),
format: parseDateToString,
parse: parseStringToDate,
lazy: false,
autofix: true,
},
{
onComplete: (value) => {
const parsedDate = parseStringToDate(value);
onChange?.(parsedDate);
},
onAccept: () => {
setHasError(false);
},
},
);
useEffect(() => {
setValue(parseDateToString(date));
}, [date, setValue]);
return (
<StyledInputContainer>
<StyledInput
type="text"
ref={ref as any}
placeholder={`Type date${
isDateTimeInput ? ' and time' : ' (mm/dd/yyyy)'
}`}
value={value}
hasError={hasError}
/>
</StyledInputContainer>
);
};

View File

@ -1,9 +1,18 @@
import { useState } from 'react';
import ReactDatePicker from 'react-datepicker';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { IconCalendarX } from 'twenty-ui';
import { IconCalendarX, IconChevronLeft, IconChevronRight } from 'twenty-ui';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import {
MONTH_AND_YEAR_DROPDOWN_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
MonthAndYearDropdown,
} from '@/ui/input/components/internal/date/components/MonthAndYearDropdown';
import { TimeInput } from '@/ui/input/components/internal/date/components/TimeInput';
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';
@ -45,13 +54,21 @@ const StyledContainer = styled.div`
& .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(1)};
margin-bottom: ${({ theme }) => theme.spacing(10)};
}
& .react-datepicker__month-dropdown-container,
@ -177,7 +194,7 @@ const StyledContainer = styled.div`
}
& .react-datepicker__navigation--previous {
right: 38px;
top: 8px;
top: 6px;
left: auto;
& > span {
@ -187,7 +204,7 @@ const StyledContainer = styled.div`
& .react-datepicker__navigation--next {
right: 6px;
top: 8px;
top: 6px;
& > span {
margin-left: 6px;
@ -239,32 +256,26 @@ const StyledButton = styled(MenuItemLeftContent)`
justify-content: start;
`;
const StyledCustomDatePickerHeader = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(1)};
`;
export type InternalDatePickerProps = {
date: Date | null;
date: Date;
onMouseSelect?: (date: Date | null) => void;
onChange?: (date: Date) => void;
onChange?: (date: Date | null) => void;
clearable?: boolean;
isDateTimeInput?: boolean;
onClickOutside?: (event: MouseEvent | TouchEvent, date: Date | null) => void;
};
const StyledInputContainer = styled.div`
width: 100%;
display: flex;
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: ${({ theme }) => theme.spacing(8)};
`;
const StyledInput = styled.input`
background: ${({ theme }) => theme.background.secondary};
border: none;
color: ${({ theme }) => theme.font.color.primary};
outline: none;
padding: 8px;
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
width: 100%;
`;
const PICKER_DATE_FORMAT = 'MM/dd/yyyy';
export const InternalDatePicker = ({
@ -273,92 +284,97 @@ export const InternalDatePicker = ({
onMouseSelect,
clearable = true,
isDateTimeInput,
onClickOutside,
}: InternalDatePickerProps) => {
const internalDate = date ?? new Date();
const dateFormatted =
DateTime.fromJSDate(internalDate).toFormat(PICKER_DATE_FORMAT);
const { closeDropdown } = useDropdown(MONTH_AND_YEAR_DROPDOWN_ID);
const { closeDropdown: closeDropdownMonthSelect } = useDropdown(
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
);
const { closeDropdown: closeDropdownYearSelect } = useDropdown(
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
);
const handleClear = () => {
closeDropdowns();
onMouseSelect?.(null);
};
const initialDate = date
? DateTime.fromJSDate(date).toFormat(PICKER_DATE_FORMAT)
: DateTime.now().toFormat(PICKER_DATE_FORMAT);
const closeDropdowns = () => {
closeDropdownYearSelect();
closeDropdownMonthSelect();
closeDropdown();
};
const [dateValue, setDateValue] = useState(initialDate);
const handleClickOutside = (event: any) => {
closeDropdowns();
onClickOutside?.(event, internalDate);
};
const dateValueAsJSDate = DateTime.fromFormat(dateValue, PICKER_DATE_FORMAT)
.isValid
? DateTime.fromFormat(dateValue, PICKER_DATE_FORMAT).toJSDate()
: null;
const handleMouseSelect = (newDate: Date) => {
closeDropdowns();
onMouseSelect?.(newDate);
};
// TODO: implement keyboard events here
return (
<StyledContainer>
<div className={clearable ? 'clearable ' : ''}>
<StyledInputContainer>
<StyledInput
type="text"
placeholder={`Type date${
isDateTimeInput ? ' and time' : ' (mm/dd/yyyy)'
}`}
inputMode="numeric"
value={dateValue}
onChange={(e) => {
const inputValue = e.target.value;
setDateValue(inputValue);
if (!isDateTimeInput) {
const parsedInputDate = DateTime.fromFormat(
inputValue,
PICKER_DATE_FORMAT,
{ zone: 'utc' },
);
const isValid = parsedInputDate.isValid;
if (isValid) {
onChange?.(parsedInputDate.toJSDate());
}
} else {
// TODO: implement time also
const parsedInputDate = DateTime.fromFormat(
inputValue,
PICKER_DATE_FORMAT,
{ zone: 'utc' },
);
const isValid = parsedInputDate.isValid;
if (isValid) {
onChange?.(parsedInputDate.toJSDate());
}
}
}}
/>
</StyledInputContainer>
<ReactDatePicker
open={true}
selected={dateValueAsJSDate}
value={dateValue}
showMonthDropdown
showYearDropdown
onChange={() => {
// We need to use onSelect here but onChange is almost redundant with onSelect but is require
selected={internalDate}
value={dateFormatted}
onChange={(newDate) => {
onChange?.(newDate);
}}
renderCustomHeader={({
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<>
<DateTimeInput
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
/>
<StyledCustomDatePickerHeader>
<TimeInput date={internalDate} onChange={onChange} />
<MonthAndYearDropdown date={internalDate} onChange={onChange} />
<LightIconButton
Icon={IconChevronLeft}
onClick={() => decreaseMonth()}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={() => increaseMonth()}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
</>
)}
customInput={<></>}
onSelect={(date: Date, event) => {
// Setting the time to midnight might sometimes return the previous day
// We set to 21:00 to avoid any timezone issues
const dateForDateField = new Date(date.setHours(21, 0, 0, 0));
setDateValue(
DateTime.fromJSDate(date).toFormat(PICKER_DATE_FORMAT),
);
const dateUTC = DateTime.fromJSDate(date, {
zone: 'utc',
}).toJSDate();
if (event?.type === 'click') {
onMouseSelect?.(isDateTimeInput ? date : dateForDateField);
handleMouseSelect?.(dateUTC);
} else {
onChange?.(isDateTimeInput ? date : dateForDateField);
onChange?.(dateUTC);
}
}}
onClickOutside={handleClickOutside}
></ReactDatePicker>
</div>
{clearable && (

View File

@ -0,0 +1,88 @@
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';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
type MonthAndYearDropdownProps = {
date: Date;
onChange?: (newDate: Date) => void;
};
const months = [
{ label: 'January', value: 0 },
{ label: 'February', value: 1 },
{ label: 'March', value: 2 },
{ label: 'April', value: 3 },
{ label: 'May', value: 4 },
{ label: 'June', value: 5 },
{ label: 'July', value: 6 },
{ label: 'August', value: 7 },
{ label: 'September', value: 8 },
{ label: 'October', value: 9 },
{ label: 'November', value: 10 },
{ label: 'December', value: 11 },
];
const years = Array.from(
{ length: 200 },
(_, i) => new Date().getFullYear() + 5 - i,
).map((year) => ({ label: year.toString(), value: year }));
export const MONTH_AND_YEAR_DROPDOWN_ID = 'date-picker-month-and-year-dropdown';
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';
export const MonthAndYearDropdown = ({
date,
onChange,
}: MonthAndYearDropdownProps) => {
const handleChangeMonth = (month: number) => {
const newDate = new Date(date);
newDate.setMonth(month);
onChange?.(newDate);
};
const handleChangeYear = (year: number) => {
const newDate = new Date(date);
newDate.setFullYear(year);
onChange?.(newDate);
};
return (
<Dropdown
dropdownId={MONTH_AND_YEAR_DROPDOWN_ID}
dropdownHotkeyScope={{
scope: TableHotkeyScope.CellEditMode,
}}
dropdownPlacement="bottom-start"
clickableComponent={
<LightIconButton Icon={IconCalendarDue} size="medium" />
}
dropdownComponents={
<DropdownMenuItemsContainer>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={months}
fullWidth
disableBlur
onChange={handleChangeMonth}
value={date.getMonth()}
/>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={handleChangeYear}
value={date.getFullYear()}
options={years}
fullWidth
disableBlur
/>
</DropdownMenuItemsContainer>
}
/>
);
};

View File

@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { useIMask } from 'react-imask';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { IconClockHour8 } from 'twenty-ui';
import { TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/TimeBlocks';
import { TIME_MASK } from '@/ui/input/components/internal/date/constants/TimeMask';
const StyledIconClock = styled(IconClockHour8)`
position: absolute;
`;
const StyledTimeInputContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin-right: 0;
padding: 0 ${({ theme }) => theme.spacing(2)};
text-align: left;
width: 136px;
height: 32px;
gap: ${({ theme }) => theme.spacing(1)};
z-index: 10;
`;
const StyledTimeInput = styled.input`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
outline: none;
font-weight: 500;
font-size: ${({ theme }) => theme.font.size.md};
margin-left: ${({ theme }) => theme.spacing(5)};
`;
type TimeInputProps = {
onChange?: (date: Date) => void;
date: Date;
};
export const TimeInput = ({ date, onChange }: TimeInputProps) => {
const handleComplete = (value: string) => {
const [hours, minutes] = value.split(':');
const newDate = new Date(date);
newDate.setHours(parseInt(hours, 10));
newDate.setMinutes(parseInt(minutes, 10));
onChange?.(newDate);
};
const { ref, setValue } = useIMask(
{
mask: TIME_MASK,
blocks: TIME_BLOCKS,
lazy: false,
},
{
onComplete: handleComplete,
},
);
useEffect(() => {
const formattedDate = DateTime.fromJSDate(date).toFormat('HH:mm');
setValue(formattedDate);
}, [date, setValue]);
return (
<StyledTimeInputContainer>
<StyledIconClock size={16} />
<StyledTimeInput type="text" ref={ref as any} />
</StyledTimeInputContainer>
);
};

View File

@ -0,0 +1,22 @@
import { IMask } from 'react-imask';
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,
},
...TIME_BLOCKS,
};

View File

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

View File

@ -0,0 +1,14 @@
import { IMask } from 'react-imask';
export const TIME_BLOCKS = {
HH: {
mask: IMask.MaskedRange, // Use MaskedRange for valid hour range (0-23)
from: 0,
to: 23,
},
mm: {
mask: IMask.MaskedRange, // Use MaskedRange for valid minute range (0-59)
from: 0,
to: 61,
},
};

View File

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

View File

@ -26,7 +26,9 @@ export {
IconBriefcase,
IconBuildingSkyscraper,
IconCalendar,
IconCalendarDue,
IconCalendarEvent,
IconCalendarTime,
IconCalendarX,
IconCheck,
IconCheckbox,
@ -40,6 +42,7 @@ export {
IconCirclePlus,
IconCircleX,
IconClick,
IconClockHour8,
IconCode,
IconCoins,
IconColorSwatch,

View File

@ -3387,6 +3387,16 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime-corejs3@npm:^7.24.4":
version: 7.24.4
resolution: "@babel/runtime-corejs3@npm:7.24.4"
dependencies:
core-js-pure: "npm:^3.30.2"
regenerator-runtime: "npm:^0.14.0"
checksum: 121bec9a0b505e2995c4b71cf480167e006e8ee423f77bccc38975bfbfbfdb191192ff03557c18fad6de8f2b85c12c49aaa4b92d1d5fe0c0e136da664129be1e
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.3, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
version: 7.23.5
resolution: "@babel/runtime@npm:7.23.5"
@ -30042,6 +30052,15 @@ __metadata:
languageName: node
linkType: hard
"imask@npm:^7.6.0":
version: 7.6.0
resolution: "imask@npm:7.6.0"
dependencies:
"@babel/runtime-corejs3": "npm:^7.24.4"
checksum: c754210124efbb5dcaa37e9e21497dc9f166e7fb5759853840e49c4cde1bac61bc12f23ca5c6150a2fa57246d762d3849ad3203f5c803fc22d928e8b546b95d1
languageName: node
linkType: hard
"immer@npm:^10.0.2":
version: 10.0.3
resolution: "immer@npm:10.0.3"
@ -40738,6 +40757,18 @@ __metadata:
languageName: node
linkType: hard
"react-imask@npm:^7.6.0":
version: 7.6.0
resolution: "react-imask@npm:7.6.0"
dependencies:
imask: "npm:^7.6.0"
prop-types: "npm:^15.8.1"
peerDependencies:
react: ">=0.14.0"
checksum: f5e7d9a865943ebf05d1c28819d8489a9f36cdeb2a005de340121636dbcb5e3b265017f2b64d7c40268bb5b16b9cf969721f371f18528bfe68a3b86cd8be2373
languageName: node
linkType: hard
"react-intersection-observer@npm:^9.5.2":
version: 9.5.3
resolution: "react-intersection-observer@npm:9.5.3"
@ -45980,6 +46011,7 @@ __metadata:
react-hook-form: "npm:^7.45.1"
react-hotkeys-hook: "npm:^4.4.4"
react-icons: "npm:^4.12.0"
react-imask: "npm:^7.6.0"
react-intersection-observer: "npm:^9.5.2"
react-loading-skeleton: "npm:^3.3.1"
react-phone-number-input: "npm:^3.3.4"