-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add date form field #9021
Add date form field #9021
Conversation
1ce4d95
to
83abb94
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR Summary
This PR adds comprehensive date form field functionality with both manual text input and date picker UI, including timezone-aware date handling and validation.
- Added new
FormDateFieldInput
component in/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateFieldInput.tsx
with unmasked date input validation and picker integration - Extracted date parsing logic into utility functions
/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts
andparseStringToDate.ts
for consistent handling - Added US-centric date format constants (
MM/dd/yyyy
) in/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/DateParserFormat.ts
which may need internationalization - Moved state management up from
DateInput
to parent components for better control flow - Added comprehensive test coverage in
FormDateFieldInput.stories.tsx
including edge cases and validation scenarios
13 file(s) reviewed, 19 comment(s)
Edit PR Review Bot Settings | Greptile
) : isFieldDate(field) ? ( | ||
<FormDateFieldInput | ||
label={field.label} | ||
defaultValue={defaultValue as string | undefined} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: defaultValue should be typed as Date | string | undefined since dates can be passed as Date objects
expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z'); | ||
}); | ||
|
||
expect(dialog).toBeVisible(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Dialog should be removed after successful date entry - currently remains visible
await Promise.all([ | ||
userEvent.type(input, '{Enter}'), | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Race condition possible between clear and Enter key - should verify input is actually empty before sending Enter
const [temporaryValue, setTemporaryValue] = | ||
useState<Nullable<Date>>(dateValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: temporaryValue state is initialized with dateValue but not updated when fieldValue changes. Add useEffect to sync or document why this is intentional
date={temporaryValue ?? new Date()} | ||
onChange={handleChange} | ||
onMouseSelect={handleMouseSelect} | ||
clearable={clearable ? clearable : false} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: redundant ternary - can be simplified to clearable || false
? DATE_TIME_PARSER_FORMAT | ||
: DATE_PARSER_FORMAT; | ||
|
||
const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: potential timezone issue - if userTimezone is undefined, Luxon will use local timezone which may cause inconsistencies
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, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: mixing UTC methods with toLocal() can lead to incorrect date conversions - consider using Luxon's built-in UTC handling instead of manual UTC getters
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure why we did that. Handling dates is really hard, and I experienced several bugs related to dates in the app. I think we'll have to work on that topic at some point.
? DateTime.fromFormat(dateAsString, parsingFormat, { zone: userTimezone }) | ||
: DateTime.fromFormat(dateAsString, parsingFormat, { zone: 'utc' }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: inconsistent timezone handling between date/datetime could cause issues - dates without times should probably use the user's timezone too to avoid off-by-one errors near midnight
if (!isValid) { | ||
return null; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: should validate dateAsString is not empty/undefined before parsing to avoid unnecessary Luxon operations
const parsedDate = isDateTimeInput | ||
? DateTime.fromFormat(dateAsString, parsingFormat, { zone: userTimezone }) | ||
: DateTime.fromFormat(dateAsString, parsingFormat, { zone: 'utc' }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: consider adding try/catch around DateTime.fromFormat() as it can throw exceptions for malformed inputs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice code! Some comments
hideHeaderInput?: boolean; | ||
temporaryValue: Nullable<Date>; | ||
setTemporaryValue: (newValue: Nullable<Date>) => void; | ||
wrapperRef: React.RefObject<HTMLElement>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you make temporaryValue
, setTemporaryValue
and wrapperRef
arguments optional? Then you can leave the instanciations if props if they are undefined. It will avoid to update packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@charlesBochet wasn't satisfied with this code, and I asked him to take a look as he had an idea of the ideal API. Thanks for your suggestion, I know this part can be improved.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not convinced by the name temporary value, but I think it's not a bad idea to let parents provide temporaryValue
, setTemporaryValue
, and wrapperRef
. It makes the component more easily customizable. I find it easier than making the arguments optional; I think it would make the child component less understandable—I don't think having an "optional" useState is common.
I can change my mind if we agree there is no better solution!
...-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx
Outdated
Show resolved
Hide resolved
...-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx
Outdated
Show resolved
Hide resolved
@@ -0,0 +1 @@ | |||
export const DATE_PARSER_FORMAT = 'MM/dd/yyyy'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can find the information in the database workspaceMember.dateFormat
. The info is persisted in the recoil state dateTimeFormatState
(see packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx
)
@@ -0,0 +1 @@ | |||
export const DATE_TIME_PARSER_FORMAT = 'MM/dd/yyyy HH:mm'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same for timeFormat
...ty-front/src/modules/object-record/record-field/form-types/components/FormDateFieldInput.tsx
Outdated
Show resolved
Hide resolved
// Inspired by the StyledInlineCellInput component. | ||
const StyledDateInputContainer = styled.div` | ||
position: relative; | ||
z-index: 1000; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would put 998 :D
Didn't is z-index: 1 enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't put much thought into it. As my comment says, I copied some part of the code of another component 😄 I will try to minimize the zIndex to 998 or 1.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried one, and it worked. That's less cool than 1000, though 😭
...ty-front/src/modules/object-record/record-field/form-types/components/FormDateFieldInput.tsx
Outdated
Show resolved
Hide resolved
persistDate(newDate); | ||
}; | ||
|
||
const handlePickerClickOutside = ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
content should be common with handlePickerEscape
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wrote the same code three times because I had taken inspiration from the other date pickers. In our case, the behavior is sometimes different. In the case of the form field date input, the input's content mustn't be reset when the user clicks outside the picker or presses Escape
.
Please note that pressing Escape
doesn't work at all. I created an issue as this problem should be solved in the InternalDatePicker
and also exists for the date picker used on the RecordTable. See #9056
persistDate(null); | ||
}; | ||
|
||
const handlePickerSubmit = (newDate: Nullable<Date>) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same
@@ -32,10 +30,13 @@ export type DateInputProps = { | |||
isDateTimeInput?: boolean; | |||
onClear?: () => void; | |||
onSubmit?: (newDate: Nullable<Date>) => void; | |||
hideHeaderInput?: boolean; | |||
temporaryValue: Nullable<Date>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really like temporaryValue as naming. It may make sense for your component but not for this one neither the other parents. Let's keep value or internalValue
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you that this could be better. I kept my v1 as Charles said he knew about a better API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically speaking, the internalValue
is, in fact, a temporary value every time the DateInput component is used.
On the Record Table, the final value is set when the user clicks outside or press enter.
In my case, I must control this internal value from the outside, and it doesn't make much sense to call it internalValue
if its parent provides it 🤔
As a consequence, I need clarification on the best API. @charlesBochet said he had a better solution in mind.
7bd6af6
to
f08fa65
Compare
Be sure to check edge cases. Previously, the component was unmounted when the field was saved. Now, we keep it displayed. We need to figure out how to solve the involved edge cases.
…and parseStringToDate functions to new files
…n empty string when the default value is a variable
…ker's event, even if that's not useful
f08fa65
to
f35ff7e
Compare
@@ -32,10 +30,13 @@ export type DateInputProps = { | |||
isDateTimeInput?: boolean; | |||
onClear?: () => void; | |||
onSubmit?: (newDate: Nullable<Date>) => void; | |||
hideHeaderInput?: boolean; | |||
temporaryValue: Nullable<Date>; | |||
setTemporaryValue: (newValue: Nullable<Date>) => void; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing a setter as props is a code smell, it means either there is an inversion of layer of abstraction (child handling parent logic for example) or that we should instead use a Recoil state.
}: DateInputProps) => { | ||
const [internalValue, setInternalValue] = useState(value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should stay here
…ke the DateInput component fit my needs
7c8c9e3
to
246a4fb
Compare
Thanks @Devessier for your contribution! |
packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx
component