Add date form field (#9021)

- Add an option to hide the input from the
`packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx`
component
- Create a form field for dates
  - Integrate the picker
- Create an input **without a mask** to let the user types a date and
validate it by pressing the Enter key
- Extract some utils to parse and format dates
This commit is contained in:
Baptiste Devessier
2024-12-17 11:11:19 +01:00
committed by GitHub
parent 5dfcc413cf
commit 5bd73e0df1
12 changed files with 886 additions and 55 deletions

View File

@ -3,6 +3,7 @@ import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/c
import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput';
import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
@ -20,6 +21,7 @@ import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFiel
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
@ -98,5 +100,12 @@ export const FormFieldInput = ({
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : isFieldDate(field) ? (
<FormDateFieldInput
label={field.label}
defaultValue={defaultValue as string | undefined}
onPersist={onPersist}
VariablePicker={VariablePicker}
/>
) : null;
};

View File

@ -0,0 +1,374 @@
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { StyledCalendarContainer } from '@/ui/field/input/components/DateInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import {
InternalDatePicker,
MONTH_AND_YEAR_DROPDOWN_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} 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 { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
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 { isDefined, Nullable, TEXT_INPUT_STYLE } from 'twenty-ui';
const StyledInputContainer = styled(FormFieldInputInputContainer)`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0px;
overflow: visible;
position: relative;
`;
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
`;
const StyledDateInput = styled.input<{ hasError?: boolean }>`
${TEXT_INPUT_STYLE}
${({ 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;
onPersist: (value: string | null) => void;
VariablePicker?: VariablePickerComponent;
};
export const FormDateFieldInput = ({
label,
defaultValue,
onPersist,
VariablePicker,
}: FormDateFieldInputProps) => {
const { timeZone } = useContext(UserContext);
const inputId = useId();
const [draftValue, setDraftValue] = useState<DraftValue>(
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue ?? null,
mode: 'view',
},
);
const draftValueAsDate = isDefined(draftValue.value)
? new Date(draftValue.value)
: null;
const [pickerDate, setPickerDate] =
useState<Nullable<Date>>(draftValueAsDate);
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const [inputDateTime, setInputDateTime] = useState(
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parseDateToString({
date: draftValueAsDate,
isDateTimeInput: false,
userTimezone: timeZone,
})
: '',
);
const persistDate = (newDate: Nullable<Date>) => {
if (!isDefined(newDate)) {
onPersist(null);
} else {
const newDateISO = newDate.toISOString();
onPersist(newDateISO);
}
};
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 displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateFieldInput',
callback: (event) => {
event.stopImmediatePropagation();
closeDropdownYearSelect();
closeDropdownMonthSelect();
closeDropdown();
handlePickerClickOutside();
},
enabled: displayDatePicker,
});
const handlePickerChange = (newDate: Nullable<Date>) => {
setDraftValue({
type: 'static',
mode: 'edit',
value: newDate?.toDateString() ?? null,
});
setInputDateTime(
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: false,
userTimezone: timeZone,
})
: '',
);
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);
setInputDateTime('');
persistDate(null);
};
const handlePickerMouseSelect = (newDate: Nullable<Date>) => {
setDraftValue({
type: 'static',
value: newDate?.toDateString() ?? null,
mode: 'view',
});
setPickerDate(newDate);
setInputDateTime(
isDefined(newDate)
? parseDateToString({
date: newDate,
isDateTimeInput: false,
userTimezone: timeZone,
})
: '',
);
persistDate(newDate);
};
const handleInputFocus = () => {
setDraftValue({
type: 'static',
mode: 'edit',
value: draftValue.value,
});
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputDateTime(event.target.value);
};
const handleInputKeydown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') {
return;
}
const inputDateTimeTrimmed = inputDateTime.trim();
if (inputDateTimeTrimmed === '') {
handlePickerClear();
return;
}
const parsedInputDateTime = parseStringToDate({
dateAsString: inputDateTimeTrimmed,
isDateTimeInput: false,
userTimezone: timeZone,
});
if (!isDefined(parsedInputDateTime)) {
return;
}
let validatedDate = parsedInputDateTime;
if (parsedInputDateTime < MIN_DATE) {
validatedDate = MIN_DATE;
} else if (parsedInputDateTime > MAX_DATE) {
validatedDate = MAX_DATE;
}
setDraftValue({
type: 'static',
value: validatedDate.toDateString(),
mode: 'edit',
});
setPickerDate(validatedDate);
setInputDateTime(
parseDateToString({
date: validatedDate,
isDateTimeInput: false,
userTimezone: timeZone,
}),
);
persistDate(validatedDate);
};
const handleVariableTagInsert = (variableName: string) => {
setDraftValue({
type: 'variable',
value: variableName,
});
setInputDateTime('');
onPersist(variableName);
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
value: null,
mode: 'view',
});
setPickerDate(null);
onPersist(null);
};
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledInputContainer
ref={datePickerWrapperRef}
hasRightElement={isDefined(VariablePicker)}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInput
type="text"
placeholder="mm/dd/yyyy"
value={inputDateTime}
onFocus={handleInputFocus}
onChange={handleInputChange}
onKeyDown={handleInputKeydown}
/>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<StyledCalendarContainer>
<InternalDatePicker
date={pickerDate ?? new Date()}
isDateTimeInput={false}
onChange={handlePickerChange}
onMouseSelect={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
/>
</StyledCalendarContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChip
rawVariableName={draftValue.value}
onRemove={handleUnlinkVariable}
/>
)}
</StyledInputContainer>
{VariablePicker ? (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,370 @@
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 { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import {
fn,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from '@storybook/test';
import { DateTime } from 'luxon';
import { FormDateFieldInput } from '../FormDateFieldInput';
const meta: Meta<typeof FormDateFieldInput> = {
title: 'UI/Data/Field/Form/Input/FormDateFieldInput',
component: FormDateFieldInput,
args: {},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormDateFieldInput>;
export const Default: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Created At');
await canvas.findByDisplayValue('12/09/2024');
},
};
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,
onPersist: 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/2024{enter}');
await waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z');
});
expect(dialog).toBeVisible();
},
};
export const SetsDateWithDatePicker: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: 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.onPersist).toHaveBeenCalledWith(
expect.stringMatching(/^2024-12-07/),
);
}),
waitFor(() => {
expect(canvas.getByDisplayValue('12/07/2024')).toBeVisible();
}),
]);
},
};
export const ResetsDateByClickingButton: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: 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.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const ResetsDateByErasingInputContent: Story = {
args: {
label: 'Created At',
defaultValue: '2024-12-09T13:20:19.631Z',
onPersist: 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/2024');
await userEvent.clear(input);
await Promise.all([
userEvent.type(input, '{Enter}'),
waitForElementToBeRemoved(() => canvas.queryByRole('dialog')),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(null);
}),
waitFor(() => {
expect(input).toHaveDisplayValue('');
}),
]);
},
};
export const DefaultsToMinValueWhenTypingReallyOldDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: 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/1500{Enter}'),
waitFor(() => {
expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MIN_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MIN_DATE)
.toLocal()
.set({
day: MIN_DATE.getUTCDate(),
month: MIN_DATE.getUTCMonth() + 1,
year: MIN_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Sunday, December 31st, 1899"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: 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.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString());
}),
waitFor(() => {
expect(input).toHaveDisplayValue(
parseDateToString({
date: MAX_DATE,
isDateTimeInput: false,
userTimezone: undefined,
}),
);
}),
waitFor(() => {
const expectedDate = DateTime.fromJSDate(MAX_DATE)
.toLocal()
.set({
day: MAX_DATE.getUTCDate(),
month: MAX_DATE.getUTCMonth() + 1,
year: MAX_DATE.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const selectedDay = within(datePicker).getByRole('option', {
selected: true,
name: (accessibleName) => {
// The name looks like "Choose Thursday, December 30th, 2100"
return accessibleName.includes(expectedDate.toFormat('yyyy'));
},
});
expect(selectedDay).toBeVisible();
}),
]);
},
};
export const SwitchesToStandaloneVariable: Story = {
args: {
label: 'Created At',
defaultValue: undefined,
onPersist: fn(),
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect('{{test}}');
}}
>
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('test');
expect(variableTag).toBeVisible();
const removeVariableButton = canvas.getByTestId(/^remove-icon/);
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: '2024-12-09T13:20:19.631Z',
onPersist: fn(),
},
play: async ({ canvasElement, args }) => {
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.onPersist).not.toHaveBeenCalled();
expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2));
},
};

View File

@ -4,8 +4,8 @@ import { useDateField } from '@/object-record/record-field/meta-types/hooks/useD
import { DateInput } from '@/ui/field/input/components/DateInput';
import { isDefined } from '~/utils/isDefined';
import { usePersistField } from '../../../hooks/usePersistField';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/meta-types/input/components/DateTimeFieldInput';
import { usePersistField } from '../../../hooks/usePersistField';
type FieldInputEvent = (persist: () => void) => void;

View File

@ -1,5 +1,4 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { Nullable } from 'twenty-ui';
import {
@ -10,8 +9,9 @@ import {
} from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useRef, useState } from 'react';
const StyledCalendarContainer = styled.div`
export const StyledCalendarContainer = styled.div`
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
border: 1px solid ${({ theme }) => theme.border.color.light};
@ -32,6 +32,7 @@ export type DateInputProps = {
isDateTimeInput?: boolean;
onClear?: () => void;
onSubmit?: (newDate: Nullable<Date>) => void;
hideHeaderInput?: boolean;
};
export const DateInput = ({
@ -44,6 +45,7 @@ export const DateInput = ({
isDateTimeInput,
onClear,
onSubmit,
hideHeaderInput,
}: DateInputProps) => {
const [internalValue, setInternalValue] = useState(value);
@ -97,6 +99,7 @@ export const DateInput = ({
onEnter={onEnter}
onEscape={onEscape}
onClear={handleClear}
hideHeaderInput={hideHeaderInput}
/>
</StyledCalendarContainer>
</div>

View File

@ -38,6 +38,7 @@ type AbsoluteDatePickerHeaderProps = {
nextMonthButtonDisabled: boolean;
isDateTimeInput?: boolean;
timeZone: string;
hideInput?: boolean;
};
export const AbsoluteDatePickerHeader = ({
@ -51,6 +52,7 @@ export const AbsoluteDatePickerHeader = ({
nextMonthButtonDisabled,
isDateTimeInput,
timeZone,
hideInput = false,
}: AbsoluteDatePickerHeaderProps) => {
const endOfDayDateTimeInLocalTimezone = DateTime.now().set({
day: date.getDate(),
@ -66,12 +68,15 @@ export const AbsoluteDatePickerHeader = ({
return (
<>
<DateTimeInput
date={date}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
{!hideInput && (
<DateTimeInput
date={date}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
)}
<StyledCustomDatePickerHeader>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}

View File

@ -1,6 +1,5 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { useCallback, useEffect, useState } from 'react';
import { useIMask } from 'react-imask';
@ -10,6 +9,10 @@ import { DATE_TIME_BLOCKS } from '@/ui/input/components/internal/date/constants/
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 { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-ui';
const StyledInputContainer = styled.div`
align-items: center;
@ -53,53 +56,29 @@ export const DateTimeInput = ({
isDateTimeInput,
userTimezone,
}: DateTimeInputProps) => {
const parsingFormat = isDateTimeInput ? 'MM/dd/yyyy HH:mm' : 'MM/dd/yyyy';
const [hasError, setHasError] = useState(false);
const parseDateToString = useCallback(
const handleParseDateToString = useCallback(
(date: any) => {
const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone });
const dateWithoutTime = DateTime.fromJSDate(date)
.toLocal()
.set({
day: date.getUTCDate(),
month: date.getUTCMonth() + 1,
year: date.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const formattedDate = isDateTimeInput
? dateParsed.setZone(userTimezone).toFormat(parsingFormat)
: dateWithoutTime.toFormat(parsingFormat);
return formattedDate;
return parseDateToString({
date,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
},
[parsingFormat, isDateTimeInput, userTimezone],
[isDateTimeInput, userTimezone],
);
const parseStringToDate = (str: string) => {
setHasError(false);
const handleParseStringToDate = (str: string) => {
const date = parseStringToDate({
dateAsString: str,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
const parsedDate = isDateTimeInput
? DateTime.fromFormat(str, parsingFormat, { zone: userTimezone })
: DateTime.fromFormat(str, parsingFormat, { zone: 'utc' });
setHasError(isNull(date) === true);
const isValid = parsedDate.isValid;
if (!isValid) {
setHasError(true);
return null;
}
const jsDate = parsedDate.toJSDate();
return jsDate;
return date;
};
const pattern = isDateTimeInput ? DATE_TIME_MASK : DATE_MASK;
@ -112,14 +91,18 @@ export const DateTimeInput = ({
blocks,
min: MIN_DATE,
max: MAX_DATE,
format: parseDateToString,
parse: parseStringToDate,
format: handleParseDateToString,
parse: handleParseStringToDate,
lazy: false,
autofix: true,
},
{
onComplete: (value) => {
const parsedDate = parseStringToDate(value);
const parsedDate = parseStringToDate({
dateAsString: value,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
});
onChange?.(parsedDate);
},
@ -130,8 +113,18 @@ export const DateTimeInput = ({
);
useEffect(() => {
setValue(parseDateToString(date));
}, [date, setValue, parseDateToString]);
if (!isDefined(date)) {
return;
}
setValue(
parseDateToString({
date: date,
isDateTimeInput: isDateTimeInput === true,
userTimezone,
}),
);
}, [date, setValue, isDateTimeInput, userTimezone]);
return (
<StyledInputContainer>

View File

@ -9,11 +9,11 @@ import {
StyledHoverableMenuItemBase,
} from 'twenty-ui';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { isDefined } from '~/utils/isDefined';
import { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
import { UserContext } from '@/users/contexts/UserContext';
@ -276,6 +276,7 @@ const StyledButton = styled(MenuItemLeftContent)`
type InternalDatePickerProps = {
isRelative?: boolean;
hideHeaderInput?: boolean;
date: Date | null;
relativeDate?: {
direction: VariableDateViewFilterValueDirection;
@ -317,6 +318,7 @@ export const InternalDatePicker = ({
relativeDate,
onRelativeDateChange,
highlightedDateRange,
hideHeaderInput,
}: InternalDatePickerProps) => {
const internalDate = date ?? new Date();
@ -510,6 +512,7 @@ export const InternalDatePicker = ({
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput}
timeZone={timeZone}
hideInput={hideHeaderInput}
/>
)
}

View File

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

View File

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

View File

@ -0,0 +1,39 @@
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 { DateTime } from 'luxon';
type ParseDateToStringArgs = {
date: Date;
isDateTimeInput: boolean;
userTimezone: string | undefined;
};
export const parseDateToString = ({
date,
isDateTimeInput,
userTimezone,
}: ParseDateToStringArgs) => {
const parsingFormat = isDateTimeInput
? DATE_TIME_PARSER_FORMAT
: DATE_PARSER_FORMAT;
const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone });
const dateWithoutTime = DateTime.fromJSDate(date)
.toLocal()
.set({
day: date.getUTCDate(),
month: date.getUTCMonth() + 1,
year: date.getUTCFullYear(),
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
});
const formattedDate = isDateTimeInput
? dateParsed.setZone(userTimezone).toFormat(parsingFormat)
: dateWithoutTime.toFormat(parsingFormat);
return formattedDate;
};

View File

@ -0,0 +1,33 @@
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 { DateTime } from 'luxon';
type ParseStringToDateArgs = {
dateAsString: string;
isDateTimeInput: boolean;
userTimezone: string | undefined;
};
export const parseStringToDate = ({
dateAsString,
isDateTimeInput,
userTimezone,
}: ParseStringToDateArgs) => {
const parsingFormat = isDateTimeInput
? DATE_TIME_PARSER_FORMAT
: DATE_PARSER_FORMAT;
const parsedDate = isDateTimeInput
? DateTime.fromFormat(dateAsString, parsingFormat, { zone: userTimezone })
: DateTime.fromFormat(dateAsString, parsingFormat, { zone: 'utc' });
const isValid = parsedDate.isValid;
if (!isValid) {
return null;
}
const jsDate = parsedDate.toJSDate();
return jsDate;
};