Fix Date/DateTime/Relation field forms (#19463)

https://discord.com/channels/1130383047699738754/1486025695481168102

On Date and DateTime, picker doesn't open when clicking.
On Relation, No record option can't be selected. Introduce the "null",
to break relation.
This commit is contained in:
Etienne
2026-04-09 16:09:44 +02:00
committed by GitHub
parent 5e3cf7cd2b
commit 74e26ae635
8 changed files with 205 additions and 119 deletions
@@ -18,8 +18,14 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import {
FloatingPortal,
autoUpdate,
flip,
offset,
useFloating,
} from '@floating-ui/react';
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useId, useRef, useState } from 'react';
import { Temporal } from 'temporal-polyfill';
import { Key } from 'ts-key-enum';
@@ -27,10 +33,8 @@ import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type Nullable } from 'twenty-ui/utilities';
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
top: ${themeCssVariables.spacing[1]};
`;
const FORM_DATE_FIELD_PICKER_CLICK_OUTSIDE_ID =
'form-date-field-picker-floating';
const StyledDateInputTextContainer = styled.div<{ isReadonly?: boolean }>`
align-items: center;
@@ -49,9 +53,10 @@ const StyledDateInputTextContainer = styled.div<{ isReadonly?: boolean }>`
}
`;
const StyledDateInputContainer = styled.div`
position: relative;
z-index: 1;
const StyledDatePickerInputWrapper = styled.div`
display: flex;
flex: 1;
min-width: 0;
`;
type DraftValue =
@@ -98,6 +103,16 @@ export const FormDateFieldInput = ({
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
const { refs, floatingStyles } = useFloating({
open: displayDatePicker,
placement: 'bottom-start',
middleware: [offset(4), flip()],
whileElementsMounted: autoUpdate,
});
const persistDate = (newDate: Nullable<string>) => {
if (!isDefined(newDate)) {
onChange(null);
@@ -109,9 +124,6 @@ export const FormDateFieldInput = ({
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateFieldInputBase',
@@ -124,6 +136,7 @@ export const FormDateFieldInput = ({
},
enabled: displayDatePicker,
excludedClickOutsideIds: [
FORM_DATE_FIELD_PICKER_CLICK_OUTSIDE_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
],
@@ -235,9 +248,7 @@ export const FormDateFieldInput = ({
: Temporal.PlainDate.from(defaultValue).toString();
const plainDateValue =
draftValue.type === 'static' && isNonEmptyString(draftValue.value)
? draftValue.value
: plainDateValueFromProps;
draftValue.type === 'static' ? draftValue.value : plainDateValueFromProps;
const handleMaskedDatePointerDownCapture = () => {
if (readonly) {
@@ -256,13 +267,13 @@ export const FormDateFieldInput = ({
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
ref={datePickerWrapperRef}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDatePickerInputWrapper ref={datePickerWrapperRef}>
<FormFieldInputInnerContainer
ref={refs.setReference}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<StyledDateInputTextContainer
isReadonly={readonly === true}
onPointerDownCapture={handleMaskedDatePointerDownCapture}
@@ -273,32 +284,38 @@ export const FormDateFieldInput = ({
readonly={readonly}
/>
</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>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
</StyledDatePickerInputWrapper>
{draftValue.type === 'static' &&
draftValue.mode === 'edit' &&
!readonly ? (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
data-click-outside-id={FORM_DATE_FIELD_PICKER_CLICK_OUTSIDE_ID}
>
<OverlayContainer>
<DatePicker
instanceId={instanceId}
plainDateString={plainDateValue}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
/>
</OverlayContainer>
</div>
</FloatingPortal>
) : null}
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
@@ -18,27 +18,37 @@ import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotke
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import {
FloatingPortal,
autoUpdate,
flip,
offset,
useFloating,
} from '@floating-ui/react';
import { styled } from '@linaria/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';
const StyledDateInputAbsoluteContainer = styled.div`
position: absolute;
top: ${themeCssVariables.spacing[1]};
`;
const FORM_DATE_TIME_FIELD_PICKER_CLICK_OUTSIDE_ID =
'form-date-time-field-picker-floating';
const StyledDateInputTextContainer = styled.div`
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%;
`;
const StyledDateInputContainer = styled.div`
position: relative;
z-index: 1;
const StyledDatePickerInputWrapper = styled.div`
display: flex;
flex: 1;
min-width: 0;
`;
type DraftValue =
@@ -87,6 +97,16 @@ export const FormDateTimeFieldInput = ({
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
const { refs, floatingStyles } = useFloating({
open: displayDatePicker,
placement: 'bottom-start',
middleware: [offset(4), flip()],
whileElementsMounted: autoUpdate,
});
const persistDate = (newDate: Nullable<Temporal.ZonedDateTime>) => {
if (!isDefined(newDate)) {
onChange(null);
@@ -100,9 +120,6 @@ export const FormDateTimeFieldInput = ({
const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown();
const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown();
const displayDatePicker =
draftValue.type === 'static' && draftValue.mode === 'edit';
useListenClickOutside({
refs: [datePickerWrapperRef],
listenerId: 'FormDateTimeFieldInputBase',
@@ -115,6 +132,7 @@ export const FormDateTimeFieldInput = ({
},
enabled: displayDatePicker,
excludedClickOutsideIds: [
FORM_DATE_TIME_FIELD_PICKER_CLICK_OUTSIDE_ID,
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
],
@@ -170,7 +188,7 @@ export const FormDateTimeFieldInput = ({
persistDate(newDate);
};
const handleInputFocus = () => {
const handleOpenPicker = () => {
setDraftValue({
type: 'static',
mode: 'edit',
@@ -241,49 +259,58 @@ export const FormDateTimeFieldInput = ({
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
ref={datePickerWrapperRef}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<>
<StyledDateInputTextContainer>
<StyledDatePickerInputWrapper ref={datePickerWrapperRef}>
<FormFieldInputInnerContainer
ref={refs.setReference}
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<StyledDateInputTextContainer
isReadonly={readonly === true}
onPointerDownCapture={handleOpenPicker}
>
<DateTimePickerInput
date={dateValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
onFocus={handleOpenPicker}
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>
) : (
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
</StyledDatePickerInputWrapper>
{draftValue.type === 'static' && draftValue.mode === 'edit' ? (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
data-click-outside-id={
FORM_DATE_TIME_FIELD_PICKER_CLICK_OUTSIDE_ID
}
>
<OverlayContainer>
<DateTimePicker
instanceId={instanceId}
date={dateValue}
onChange={handlePickerChange}
onClose={handlePickerMouseSelect}
onEnter={handlePickerEnter}
onEscape={handlePickerEscape}
onClear={handlePickerClear}
hideHeaderInput
timeZone={timeZone}
/>
</OverlayContainer>
</div>
</FloatingPortal>
) : null}
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
@@ -11,7 +11,7 @@ import { type JsonValue } from 'type-fest';
export type FormRelationToOneFieldInputProps = {
label?: string;
objectNameSingular?: string;
defaultValue?: FieldRelationValue<FieldRelationToOneValue> | string;
defaultValue?: FieldRelationValue<FieldRelationToOneValue> | string | null;
onChange: (value: JsonValue) => void;
onClear?: () => void;
readonly?: boolean;
@@ -8,8 +8,9 @@ import { VariableChipStandalone } from '@/object-record/record-field/ui/form-typ
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconForbid } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRecordChipContainer = styled.div`
@@ -20,6 +21,14 @@ const StyledPlaceholderContainer = styled.div`
margin: ${themeCssVariables.spacing[2]};
`;
const StyledNoRecordContainer = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
gap: ${themeCssVariables.spacing[1]};
margin: ${themeCssVariables.spacing[2]};
`;
type FormSingleRecordFieldChipProps = {
draftValue:
| {
@@ -29,6 +38,10 @@ type FormSingleRecordFieldChipProps = {
| {
type: 'variable';
value: Variable;
}
| {
type: 'no-record';
value: null;
};
selectedRecord?: ObjectRecord;
objectNameSingular: string;
@@ -56,6 +69,15 @@ export const FormSingleRecordFieldChip = ({
);
}
if (draftValue.type === 'no-record') {
return (
<StyledNoRecordContainer>
<IconForbid size={12} />
{t`No record`}
</StyledNoRecordContainer>
);
}
if (draftValue.type === 'static' && isDefined(selectedRecord)) {
return (
<StyledRecordChipContainer>
@@ -16,7 +16,6 @@ import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSe
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback, useContext, useId } from 'react';
import { CustomError, isDefined, isValidUuid } from 'twenty-shared/utils';
import { IconChevronDown, IconForbid } from 'twenty-ui/display';
@@ -45,11 +44,15 @@ type FormSingleRecordPickerValue =
| {
type: 'variable';
value: Variable;
}
| {
type: 'no-record';
value: null;
};
export type FormSingleRecordPickerProps = {
label?: string;
defaultValue?: RecordId | Variable;
defaultValue?: RecordId | Variable | null;
onChange: (value: RecordId | Variable | null) => void;
onClear?: () => void;
objectNameSingulars: string[];
@@ -70,17 +73,12 @@ export const FormSingleRecordPicker = ({
}: FormSingleRecordPickerProps) => {
const { theme } = useContext(ThemeContext);
const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString(
defaultValue,
)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: (defaultValue as string | undefined) ?? '',
};
const draftValue: FormSingleRecordPickerValue =
defaultValue === null
? { type: 'no-record', value: null }
: isStandaloneVariableString(defaultValue)
? { type: 'variable', value: defaultValue }
: { type: 'static', value: (defaultValue as string | undefined) ?? '' };
if (objectNameSingulars.length === 0) {
throw new CustomError(
@@ -117,13 +115,22 @@ export const FormSingleRecordPicker = ({
const handleMorphItemSelected = (
selectedMorphItem: RecordPickerPickableMorphItem | null | undefined,
) => {
if (!isNonEmptyString(selectedMorphItem?.recordId)) {
onClear?.();
if (!isDefined(selectedMorphItem) || selectedMorphItem === null) {
if (defaultValue === null) {
onClear?.();
} else {
onChange(null);
}
closeDropdown(dropdownId);
return;
}
onChange(selectedMorphItem.recordId);
if (defaultValue === selectedMorphItem.recordId) {
onClear?.();
} else {
onChange(selectedMorphItem.recordId);
}
closeDropdown(dropdownId);
};
@@ -142,8 +149,13 @@ export const FormSingleRecordPicker = ({
);
const handleOpenDropdown = () => {
if (defaultValue === null) {
setSingleRecordPickerSelectedId(undefined);
return;
}
if (
isDefined(draftValue?.value) &&
isDefined(draftValue.value) &&
!isStandaloneVariableString(draftValue.value)
) {
setSingleRecordPickerSelectedId(draftValue.value);
@@ -208,7 +220,7 @@ export const FormSingleRecordPicker = ({
focusId={dropdownId}
componentInstanceId={dropdownId}
EmptyIcon={IconForbid}
emptyLabel={t`No records`}
emptyLabel={t`No record`}
onCancel={() => closeDropdown(dropdownId)}
onMorphItemSelected={handleMorphItemSelected}
objectNameSingulars={objectNameSingulars}
@@ -65,7 +65,9 @@ export const UpdateMultipleRecordsForm = ({
const value = values[fieldNameOrRelationIdName];
const handleValueChange = (newValue: any) => {
if (isUpdateRecordValueEmpty(newValue)) {
if (newValue === null) {
onChange(fieldNameOrRelationIdName, null);
} else if (isUpdateRecordValueEmpty(newValue)) {
onChange(fieldNameOrRelationIdName, undefined);
} else {
onChange(fieldNameOrRelationIdName, newValue);
@@ -79,6 +81,7 @@ export const UpdateMultipleRecordsForm = ({
field={fieldDefinition}
defaultValue={value}
onChange={handleValueChange}
onClear={() => onChange(fieldNameOrRelationIdName, undefined)}
/>
);
})}
@@ -100,9 +100,13 @@ export const DatePickerInput = ({
);
useEffect(() => {
if (isDefined(date) && internalDate !== date) {
if (internalDate !== date) {
setInternalDate(date);
setValue(parsePlainDateToDateInputString(date));
if (isDefined(date)) {
setValue(parsePlainDateToDateInputString(date));
} else {
setValue('');
}
}
}, [date, internalDate, parsePlainDateToDateInputString, setValue]);
@@ -138,6 +138,7 @@ export const DateTimePickerInput = ({
setInternalDate(date);
if (!isDefined(date)) {
setValue('');
return;
}