Fix workflow date fields (#19210)

Before: broken on forms, missing border right, no fullWidth, not
properly saved
<img width="220" height="160" alt="Capture d’écran 2026-03-31 à 17 10
47"
src="https://github.com/user-attachments/assets/4143fcb7-909f-42a3-b05e-39185395f657"
/> <img width="231" height="102" alt="Capture d’écran 2026-03-31 à 17
11 05"
src="https://github.com/user-attachments/assets/3989f6b8-ef8a-42a3-9ccc-35a9be1fb67f"
/>
<img width="230" height="75" alt="Capture d’écran 2026-03-31 à 17 12
15"
src="https://github.com/user-attachments/assets/67f5f93f-887f-4e3b-95c2-f5076f41fb21"
/>

After: save and validate on edition, fix design
<img width="231" height="132" alt="Capture d’écran 2026-03-31 à 17 15
08"
src="https://github.com/user-attachments/assets/d1aa0a64-499d-479d-8d5c-e5e104ad6464"
/>
<img width="231" height="75" alt="Capture d’écran 2026-03-31 à 17 15
33"
src="https://github.com/user-attachments/assets/8d668713-8466-4e81-8901-4b12b9271244"
/>
<img width="461" height="156" alt="Capture d’écran 2026-03-31 à 17 14
54"
src="https://github.com/user-attachments/assets/f9d33242-f1cc-48f7-9f63-322799e1f9b8"
/>

Simplifying FormDateInput using the existing DatePicker
This commit is contained in:
Thomas Trompette
2026-04-01 16:44:24 +02:00
committed by GitHub
parent a0c6727a61
commit 19dd4d6c1b
6 changed files with 153 additions and 306 deletions
@@ -1,76 +1,52 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { DatePicker } from '@/ui/input/components/internal/date/components/DatePicker';
import {
DatePicker,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
} from '@/ui/input/components/internal/date/components/DateTimePicker';
import { useParseDateInputStringToPlainDate } from '@/ui/input/components/internal/date/hooks/useParseDateInputStringToPlainDate';
import { useParsePlainDateToDateInputString } from '@/ui/input/components/internal/date/hooks/useParsePlainDateToDateInputString';
} from '@/ui/input/components/internal/date/components/DatePicker';
import { DatePickerInput } from '@/ui/input/components/internal/date/components/DatePickerInput';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import {
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from 'react';
import { useId, useRef, useState } from 'react';
import { Temporal } from 'temporal-polyfill';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type Nullable } from 'twenty-ui/utilities';
import { getDateFormatStringForDatePickerInputMask } from '~/utils/date-utils';
const StyledInputContainerWrapper = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0;
overflow: visible;
position: relative;
`;
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
top: ${themeCssVariables.spacing[1]};
`;
const StyledDateInput = styled.input<{ hasError?: boolean }>`
background-color: transparent;
border: none;
color: ${({ hasError }) =>
hasError
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: inherit;
font-weight: inherit;
outline: none;
const StyledDateInputTextContainer = styled.div<{ isReadonly?: boolean }>`
align-items: center;
display: flex;
flex: 1;
min-width: 0;
pointer-events: ${({ isReadonly }) => (isReadonly ? 'none' : 'auto')};
user-select: ${({ isReadonly }) => (isReadonly ? 'none' : 'auto')};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.light};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
& input {
color: ${({ isReadonly }) =>
isReadonly
? themeCssVariables.font.color.light
: themeCssVariables.font.color.primary};
}
&:disabled {
color: ${themeCssVariables.font.color.tertiary};
}
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
`;
const StyledDateInputContainer = styled.div`
@@ -104,15 +80,8 @@ export const FormDateFieldInput = ({
onChange,
VariablePicker,
readonly,
placeholder,
}: FormDateFieldInputProps) => {
const instanceId = useId();
const { dateFormat } = useDateTimeFormat();
const { parsePlainDateToDateInputString } =
useParsePlainDateToDateInputString();
const { parseDateInputStringToPlainDate } =
useParseDateInputStringToPlainDate();
const [draftValue, setDraftValue] = useState<DraftValue>(
isStandaloneVariableString(defaultValue)
@@ -127,24 +96,8 @@ export const FormDateFieldInput = ({
},
);
const draftValueAsDate =
isDefined(draftValue.value) &&
isNonEmptyString(draftValue.value) &&
draftValue.type === 'static'
? draftValue.value
: null;
const [pickerDate, setPickerDate] =
useState<Nullable<string>>(draftValueAsDate);
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const [inputDate, setInputDate] = useState(
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
? parsePlainDateToDateInputString(draftValueAsDate)
: '',
);
const persistDate = (newDate: Nullable<string>) => {
if (!isDefined(newDate)) {
onChange(null);
@@ -159,14 +112,9 @@ export const FormDateFieldInput = ({
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
const defaultPlaceHolder =
getDateFormatStringForDatePickerInputMask(dateFormat);
const placeholderToDisplay = placeholder ?? defaultPlaceHolder;
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateTimeFieldInputBase',
listenerId: 'FormDateFieldInputBase',
callback: (event) => {
event.stopImmediatePropagation();
@@ -188,12 +136,6 @@ export const FormDateFieldInput = ({
value: newDate ?? null,
});
setInputDate(
isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '',
);
setPickerDate(newDate);
persistDate(newDate);
};
@@ -222,10 +164,6 @@ export const FormDateFieldInput = ({
mode: 'view',
});
setPickerDate(null);
setInputDate('');
persistDate(null);
};
@@ -236,58 +174,21 @@ export const FormDateFieldInput = ({
mode: 'view',
});
setPickerDate(newDate);
setInputDate(
isDefined(newDate) ? parsePlainDateToDateInputString(newDate) : '',
);
persistDate(newDate);
};
const handleInputFocus = () => {
const handleInputChange = (newDate: string | null) => {
if (!isDefined(newDate)) {
return;
}
setDraftValue({
type: 'static',
mode: 'edit',
value: draftValue.value,
});
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setInputDate(event.target.value);
};
const handleInputKeydown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') {
return;
}
const inputDateTime = inputDate.trim();
if (inputDateTime === '') {
handlePickerClear();
return;
}
const parsedInputPlainDate = parseDateInputStringToPlainDate(inputDateTime);
if (!isDefined(parsedInputPlainDate)) {
return;
}
let validatedDate = parsedInputPlainDate;
setDraftValue({
type: 'static',
value: validatedDate,
mode: 'edit',
value: newDate,
});
setPickerDate(validatedDate);
setInputDate(parsePlainDateToDateInputString(validatedDate));
persistDate(validatedDate);
persistDate(newDate);
};
const handleVariableTagInsert = (variableName: string) => {
@@ -296,8 +197,6 @@ export const FormDateFieldInput = ({
value: variableName,
});
setInputDate('');
onChange(variableName);
};
@@ -308,8 +207,6 @@ export const FormDateFieldInput = ({
mode: 'view',
});
setPickerDate(null);
onChange(null);
};
@@ -320,56 +217,87 @@ export const FormDateFieldInput = ({
dependencies: [handlePickerEscape],
});
const { userTimezone } = useUserTimezone();
const isVariable = Boolean(isStandaloneVariableString(defaultValue));
const plainDateValueFromProps =
isVariable ||
!isDefined(defaultValue) ||
defaultValue === '' ||
defaultValue === 'null'
? null
: defaultValue.includes('T')
? Temporal.Instant.from(defaultValue)
.toZonedDateTimeISO(userTimezone)
.toPlainDate()
.toString()
: Temporal.PlainDate.from(defaultValue).toString();
const plainDateValue =
draftValue.type === 'static' && isNonEmptyString(draftValue.value)
? draftValue.value
: plainDateValueFromProps;
const handleMaskedDatePointerDownCapture = () => {
if (readonly) {
return;
}
setDraftValue((previous) =>
previous.type === 'static' && previous.mode === 'view'
? { ...previous, mode: 'edit' }
: previous,
);
};
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledInputContainerWrapper ref={datePickerWrapperRef}>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInput
type="text"
placeholder={placeholderToDisplay}
value={inputDate}
onFocus={handleInputFocus}
<FormFieldInputInnerContainer
ref={datePickerWrapperRef}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInputTextContainer
isReadonly={readonly === true}
onPointerDownCapture={handleMaskedDatePointerDownCapture}
>
<DatePickerInput
date={plainDateValue}
onChange={handleInputChange}
onKeyDown={handleInputKeydown}
disabled={readonly}
/>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<DatePicker
instanceId={instanceId}
plainDateString={pickerDate}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
/>
</OverlayContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
</StyledInputContainerWrapper>
</StyledDateInputTextContainer>
{draftValue.mode === 'edit' && !readonly ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<DatePicker
instanceId={instanceId}
plainDateString={plainDateValue}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
/>
</OverlayContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
@@ -26,14 +26,6 @@ import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type Nullable } from 'twenty-ui/utilities';
const StyledInputContainerWrapper = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr 0;
overflow: visible;
position: relative;
`;
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
top: ${themeCssVariables.spacing[1]};
@@ -249,50 +241,49 @@ export const FormDateTimeFieldInput = ({
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledInputContainerWrapper ref={datePickerWrapperRef}>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInputTextContainer>
<DateTimePickerInput
date={dateValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
readonly={readonly}
timeZone={timeZone}
/>
</StyledDateInputTextContainer>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<DateTimePicker
instanceId={instanceId}
date={dateValue}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
timeZone={timeZone}
/>
</OverlayContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
</StyledInputContainerWrapper>
<FormFieldInputInnerContainer
ref={datePickerWrapperRef}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInputTextContainer>
<DateTimePickerInput
date={dateValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
readonly={readonly}
timeZone={timeZone}
/>
</StyledDateInputTextContainer>
{draftValue.mode === 'edit' ? (
<StyledDateInputContainer>
<StyledDateInputAbsoluteContainer>
<OverlayContainer>
<DateTimePicker
instanceId={instanceId}
date={dateValue}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
timeZone={timeZone}
/>
</OverlayContainer>
</StyledDateInputAbsoluteContainer>
</StyledDateInputContainer>
) : null}
</>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
@@ -24,7 +24,9 @@ const StyledFormFieldInputInnerContainer = styled.div<
border-bottom-right-radius: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? themeCssVariables.border.radius.sm : '0'};
border-right: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? 'auto' : 'none'};
multiline || !hasRightElement
? `1px solid ${themeCssVariables.border.color.medium}`
: 'none'};
border-top-left-radius: ${themeCssVariables.border.radius.sm};
border-top-right-radius: ${({ multiline, hasRightElement }) =>
multiline || !hasRightElement ? themeCssVariables.border.radius.sm : '0'};
@@ -17,8 +17,8 @@ import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUs
import { useEffect, useState } from 'react';
import { Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { isDifferentZonedDateTime } from '~/utils/dates/isDifferentZonedDateTime';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isDifferentZonedDateTime } from '~/utils/dates/isDifferentZonedDateTime';
const StyledInputContainer = styled.div`
align-items: center;
@@ -14,8 +14,8 @@ import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/Workflo
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { styled } from '@linaria/react';
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext, useEffect, useState } from 'react';
@@ -30,9 +30,9 @@ import {
IconTrash,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
import { themeCssVariables, ThemeContext } from 'twenty-ui/theme-constants';
export type WorkflowEditActionFormBuilderProps = {
triggerType: WorkflowTriggerType | undefined;
@@ -98,9 +98,10 @@ const StyledFieldContainer = styled.div<{
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
display: flex;
font-family: inherit;
height: 100%;
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
width: 100%;
&:hover,
@@ -81,43 +81,6 @@ const mockAction: WorkflowFormAction = {
},
};
// TEMP_DISABLED_TEST: Commented out unused mock data
// const mockActionWithDuplicatedRecordFields: WorkflowFormAction = {
// id: 'form-action-1',
// type: 'FORM',
// name: 'Test Form',
// valid: true,
// settings: {
// input: [
// {
// id: 'field-1',
// name: 'record',
// label: 'Record',
// type: 'RECORD',
// placeholder: 'Select a record',
// settings: {
// objectName: 'company',
// },
// },
// {
// id: 'field-2',
// name: 'record',
// label: 'Record',
// type: 'RECORD',
// placeholder: 'Select a record',
// settings: {
// objectName: 'company',
// },
// },
// ],
// outputSchema: {},
// errorHandlingOptions: {
// retryOnFailure: { value: false },
// continueOnFailure: { value: false },
// },
// },
// };
export const Default: Story = {
args: {
action: mockAction,
@@ -158,45 +121,7 @@ export const ReadonlyMode: Story = {
const numberInput = await canvas.findByPlaceholderText('Enter number');
expect(numberInput).toBeDisabled();
const dateInput = await canvas.findByPlaceholderText('mm/dd/yyyy');
expect(dateInput).toBeDisabled();
const submitButton = canvas.queryByText('Submit');
expect(submitButton).not.toBeInTheDocument();
},
};
// TEMP_DISABLED_TEST: Temporarily commented out due to test failure
// export const CanHaveManyRecordFieldsForTheSameRecordType: Story = {
// args: {
// action: mockActionWithDuplicatedRecordFields,
// actionOptions: {
// readonly: false,
// },
// },
// play: async ({ canvasElement }) => {
// const canvas = within(canvasElement);
// const recordSelects = await waitFor(() => {
// const elements = canvas.getAllByText('Select a company');
// expect(elements.length).toBe(2);
// return elements;
// });
// for (const recordSelect of recordSelects) {
// expect(recordSelect).toBeVisible();
// await userEvent.click(recordSelect);
// await waitFor(() => {
// expect(
// within(document.body).getByText('Louis Duss'),
// ).toBeVisible();
// });
// await userEvent.click(canvasElement);
// }
// },
// };