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:
committed by
GitHub
parent
5dfcc413cf
commit
5bd73e0df1
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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));
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const DATE_PARSER_FORMAT = 'MM/dd/yyyy';
|
||||
@ -0,0 +1 @@
|
||||
export const DATE_TIME_PARSER_FORMAT = 'MM/dd/yyyy HH:mm';
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user