Fix bug on Links, Array, Emails, Phones Field inputs (#16603)

The following PR introduced several bugs:
https://github.com/twentyhq/twenty/pull/16042



https://github.com/user-attachments/assets/2c0fd211-3579-4a22-9a5f-dcec9cbe6a2e
This commit is contained in:
Charles Bochet
2025-12-16 18:18:15 +01:00
committed by GitHub
parent 576019e465
commit 58c85e7cca
15 changed files with 268 additions and 193 deletions

View File

@@ -7,19 +7,6 @@ export const useRecordFieldInput = <FieldValue>() => {
const recordFieldInputDraftValueCallbackState =
useRecoilComponentCallbackState(recordFieldInputDraftValueComponentState);
const getLatestDraftValue = useRecoilCallback(
({ snapshot }) =>
(instanceId: string) =>
snapshot
.getLoadable(
recordFieldInputDraftValueComponentState.atomFamily({
instanceId,
}),
)
.getValue() as FieldInputDraftValue<FieldValue>,
[],
);
const setDraftValue = useRecoilCallback(
({ set }) =>
(newValue: unknown) => {
@@ -43,7 +30,6 @@ export const useRecordFieldInput = <FieldValue>() => {
};
return {
getLatestDraftValue,
setDraftValue,
isDraftValueEmpty,
};

View File

@@ -24,8 +24,7 @@ export const useArrayField = () => {
}),
);
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldArrayValue>();
const { setDraftValue } = useRecordFieldInput<FieldArrayValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -35,7 +34,6 @@ export const useArrayField = () => {
fieldValue,
fieldDefinition,
draftValue,
getLatestDraftValue,
setFieldValue,
setDraftValue,
};

View File

@@ -26,8 +26,7 @@ export const useEmailsField = () => {
}),
);
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldEmailsValue>();
const { setDraftValue } = useRecordFieldInput<FieldEmailsValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -37,7 +36,6 @@ export const useEmailsField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};

View File

@@ -26,8 +26,7 @@ export const useLinksField = () => {
}),
);
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldLinksValue>();
const { setDraftValue } = useRecordFieldInput<FieldLinksValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -37,7 +36,6 @@ export const useLinksField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};

View File

@@ -26,8 +26,7 @@ export const usePhonesField = () => {
}),
);
const { getLatestDraftValue, setDraftValue } =
useRecordFieldInput<FieldPhonesValue>();
const { setDraftValue } = useRecordFieldInput<FieldPhonesValue>();
const draftValue = useRecoilComponentValue(
recordFieldInputDraftValueComponentState,
@@ -37,7 +36,6 @@ export const usePhonesField = () => {
fieldDefinition,
fieldValue,
draftValue,
getLatestDraftValue,
setDraftValue,
setFieldValue,
};

View File

@@ -2,35 +2,38 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts
import { useArrayField } from '@/object-record/record-field/ui/meta-types/hooks/useArrayField';
import { ArrayFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/ArrayFieldMenuItem';
import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { arraySchema } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const ArrayFieldInput = () => {
const { getLatestDraftValue, setDraftValue, draftValue, fieldDefinition } =
useArrayField();
const { setDraftValue, draftValue, fieldDefinition } = useArrayField();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
const { onEscape, onClickOutside, onEnter } = useContext(
FieldInputEventContext,
);
const arrayItems = useMemo<Array<string>>(
() => (Array.isArray(draftValue) ? draftValue : []),
[draftValue],
);
const parseStringArrayToArrayValue = (arrayItems: string[]) => {
const parseResponse = arraySchema.safeParse(arrayItems);
if (parseResponse.success) {
return parseResponse.data;
}
};
const handleChange = (newValue: any[]) => {
if (!isDefined(newValue)) setDraftValue(null);
const parseResponse = arraySchema.safeParse(newValue);
const nextValue = parseStringArrayToArrayValue(newValue);
if (parseResponse.success) {
setDraftValue(parseResponse.data);
if (isDefined(nextValue)) {
setDraftValue(nextValue);
}
};
@@ -38,12 +41,15 @@ export const ArrayFieldInput = () => {
_newValue: any,
event: MouseEvent | TouchEvent,
) => {
const latestDraftValue = getLatestDraftValue(instanceId);
onClickOutside?.({ newValue: latestDraftValue, event });
onClickOutside?.({ newValue: draftValue, event });
};
const handleEscape = (_newValue: any) => {
onEscape?.({ newValue: draftValue });
const handleEscape = (newValue: string[]) => {
onEscape?.({ newValue: parseStringArrayToArrayValue(newValue) });
};
const handleEnter = (newValue: string[]) => {
onEnter?.({ newValue: parseStringArrayToArrayValue(newValue) });
};
const maxNumberOfValues =
@@ -55,6 +61,7 @@ export const ArrayFieldInput = () => {
newItemLabel="Add Item"
items={arrayItems}
onChange={handleChange}
onEnter={handleEnter}
onEscape={handleEscape}
onClickOutside={handleClickOutside}
placeholder="Enter value"
@@ -62,7 +69,7 @@ export const ArrayFieldInput = () => {
renderItem={({ value, index, handleEdit, handleDelete }) => (
<ArrayFieldMenuItem
key={index}
dropdownId={`array-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
dropdownId={`${MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX}-${fieldDefinition.metadata.fieldName}-${index}`}
value={value}
onEdit={handleEdit}
onDelete={handleDelete}

View File

@@ -1,11 +1,11 @@
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useEmailsField } from '@/object-record/record-field/ui/meta-types/hooks/useEmailsField';
import { EmailsFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/EmailsFieldMenuItem';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { emailsSchema } from '@/object-record/record-field/ui/types/guards/isFieldEmailsValue';
import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/emailSchema';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo } from 'react';
@@ -16,14 +16,12 @@ import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { MultiItemFieldInput } from './MultiItemFieldInput';
export const EmailsFieldInput = () => {
const { getLatestDraftValue, setDraftValue, draftValue, fieldDefinition } =
useEmailsField();
const { setDraftValue, draftValue, fieldDefinition } = useEmailsField();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
const { onEscape, onClickOutside, onEnter } = useContext(
FieldInputEventContext,
);
const emails = useMemo<string[]>(
@@ -35,10 +33,10 @@ export const EmailsFieldInput = () => {
[draftValue?.primaryEmail, draftValue?.additionalEmails],
);
const handleChange = (updatedEmails: string[]) => {
const [nextPrimaryEmail, ...nextAdditionalEmails] = updatedEmails;
const parseStringArrayToEmailsValue = (emails: string[]) => {
const [nextPrimaryEmail, ...nextAdditionalEmails] = emails;
const nextValue = {
const nextValue: FieldEmailsValue = {
primaryEmail: nextPrimaryEmail ?? '',
additionalEmails: nextAdditionalEmails,
};
@@ -46,7 +44,15 @@ export const EmailsFieldInput = () => {
const parseResponse = emailsSchema.safeParse(nextValue);
if (parseResponse.success) {
setDraftValue(parseResponse.data);
return parseResponse.data;
}
};
const handleChange = (updatedEmails: string[]) => {
const nextValue = parseStringArrayToEmailsValue(updatedEmails);
if (isDefined(nextValue)) {
setDraftValue(nextValue);
}
};
@@ -75,15 +81,21 @@ export const EmailsFieldInput = () => {
};
const handleClickOutside = (
_newValue: any,
updatedEmails: string[],
event: MouseEvent | TouchEvent,
) => {
const latestDraftValue = getLatestDraftValue(instanceId);
onClickOutside?.({ newValue: latestDraftValue, event });
onClickOutside?.({
newValue: parseStringArrayToEmailsValue(updatedEmails),
event,
});
};
const handleEscape = (_newValue: any) => {
onEscape?.({ newValue: draftValue });
const handleEscape = (updatedEmails: string[]) => {
onEscape?.({ newValue: parseStringArrayToEmailsValue(updatedEmails) });
};
const handleEnter = (updatedEmails: string[]) => {
onEnter?.({ newValue: parseStringArrayToEmailsValue(updatedEmails) });
};
const maxNumberOfValues =
@@ -94,6 +106,7 @@ export const EmailsFieldInput = () => {
<MultiItemFieldInput
items={emails}
onChange={handleChange}
onEnter={handleEnter}
onEscape={handleEscape}
onClickOutside={handleClickOutside}
placeholder="Email"
@@ -108,7 +121,7 @@ export const EmailsFieldInput = () => {
}) => (
<EmailsFieldMenuItem
key={index}
dropdownId={`emails-${index}`}
dropdownId={`${MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX}-${fieldDefinition.metadata.fieldName}-${index}`}
showPrimaryIcon={getShowPrimaryIcon(index)}
showSetAsPrimaryButton={getShowSetAsPrimaryButton(index)}
showCopyButton={true}

View File

@@ -1,48 +1,56 @@
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useLinksField } from '@/object-record/record-field/ui/meta-types/hooks/useLinksField';
import { LinksFieldMenuItem } from '@/object-record/record-field/ui/meta-types/input/components/LinksFieldMenuItem';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/ui/meta-types/input/utils/getFieldLinkDefinedLinks';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/ui/states/recordFieldInputIsFieldInErrorComponentState';
import { type FieldLinksValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { linksSchema } from '@/object-record/record-field/ui/types/guards/isFieldLinksValue';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { absoluteUrlSchema } from 'twenty-shared/utils';
import { absoluteUrlSchema, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput';
export const LinksFieldInput = () => {
const { getLatestDraftValue, draftValue, fieldDefinition, setDraftValue } =
useLinksField();
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
);
type LinkRecord = {
url: string | null;
label: string | null;
};
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
export const LinksFieldInput = () => {
const { draftValue, fieldDefinition, setDraftValue } = useLinksField();
const { onEscape, onClickOutside, onEnter } = useContext(
FieldInputEventContext,
);
const links = useMemo<{ url: string; label: string | null }[]>(
() => getFieldLinkDefinedLinks(draftValue),
[draftValue],
);
const handleChange = (
updatedLinks: { url: string | null; label: string | null }[],
) => {
const nextPrimaryLink = updatedLinks.at(0);
const nextSecondaryLinks = updatedLinks.slice(1);
const nextValue = {
const parseArrayToLinksValue = (links: LinkRecord[]) => {
const nextPrimaryLink = links.at(0);
const nextSecondaryLinks = links.slice(1);
const nextValue: FieldLinksValue = {
primaryLinkUrl: nextPrimaryLink?.url ?? null,
primaryLinkLabel: nextPrimaryLink?.label ?? null,
secondaryLinks: nextSecondaryLinks,
};
const parseResponse = linksSchema.safeParse(nextValue);
if (parseResponse.success) {
setDraftValue(parseResponse.data);
return parseResponse.data;
}
};
const handleChange = (
updatedLinks: { url: string | null; label: string | null }[],
) => {
const nextValue = parseArrayToLinksValue(updatedLinks);
if (isDefined(nextValue)) {
setDraftValue(nextValue);
}
};
@@ -58,15 +66,18 @@ export const LinksFieldInput = () => {
};
const handleClickOutside = (
_newValue: any,
updatedLinks: LinkRecord[],
event: MouseEvent | TouchEvent,
) => {
const latestDraftValue = getLatestDraftValue(instanceId);
onClickOutside?.({ newValue: latestDraftValue, event });
onClickOutside?.({ newValue: parseArrayToLinksValue(updatedLinks), event });
};
const handleEscape = (_newValue: any) => {
onEscape?.({ newValue: draftValue });
const handleEscape = (updatedLinks: LinkRecord[]) => {
onEscape?.({ newValue: parseArrayToLinksValue(updatedLinks) });
};
const handleEnter = (updatedLinks: LinkRecord[]) => {
onEnter?.({ newValue: parseArrayToLinksValue(updatedLinks) });
};
const maxNumberOfValues =
@@ -78,6 +89,7 @@ export const LinksFieldInput = () => {
items={links}
onChange={handleChange}
onEscape={handleEscape}
onEnter={handleEnter}
onClickOutside={handleClickOutside}
placeholder="URL"
fieldMetadataType={FieldMetadataType.LINKS}
@@ -96,7 +108,7 @@ export const LinksFieldInput = () => {
}) => (
<LinksFieldMenuItem
key={index}
dropdownId={`links-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
dropdownId={`${MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX}-${fieldDefinition.metadata.fieldName}-${index}`}
showPrimaryIcon={getShowPrimaryIcon(index)}
showSetAsPrimaryButton={getShowSetAsPrimaryButton(index)}
label={link.label}

View File

@@ -5,15 +5,19 @@ import {
MultiItemBaseInput,
type MultiItemBaseInputProps,
} from '@/object-record/record-field/ui/meta-types/input/components/MultiItemBaseInput';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { type PhoneRecord } from '@/object-record/record-field/ui/types/FieldMetadata';
import { PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID } from '@/ui/input/components/internal/phone/constants/PhoneCountryCodePickerDropdownId';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { CustomError } from 'twenty-shared/utils';
import { useRecoilValue } from 'recoil';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconCheck, IconPlus } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
@@ -25,7 +29,10 @@ import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmp
type MultiItemFieldInputProps<T> = {
items: T[];
onChange: (newItemsValue: T[]) => void;
onEscape?: (newItemsValue: T[]) => void;
onEscape: (newItemsValue: T[]) => void;
onEnter: (newItemsValue: T[]) => void;
onClickOutside: (newItemsValue: T[], event: MouseEvent | TouchEvent) => void;
onError?: (hasError: boolean, values: any[]) => void;
placeholder: string;
validateInput?: (input: string) => { isValid: boolean; errorMessage: string };
formatInput?: (input: string) => T;
@@ -39,8 +46,6 @@ type MultiItemFieldInputProps<T> = {
newItemLabel?: string;
fieldMetadataType: FieldMetadataType;
renderInput?: MultiItemBaseInputProps['renderInput'];
onClickOutside?: (newItemsValue: T[], event: MouseEvent | TouchEvent) => void;
onError?: (hasError: boolean, values: any[]) => void;
maxItemCount?: number;
};
@@ -50,6 +55,8 @@ export const MultiItemFieldInput = <T,>({
items,
onChange,
onEscape,
onEnter,
onError,
placeholder,
validateInput,
formatInput,
@@ -58,36 +65,39 @@ export const MultiItemFieldInput = <T,>({
fieldMetadataType,
renderInput,
onClickOutside,
onError,
maxItemCount,
}: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleEscape = () => {
onEscape?.(items);
};
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
);
const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
handleSubmitInput();
onClickOutside?.(items, event);
if (
(isDefined(activeDropdownFocusId) &&
activeDropdownFocusId.startsWith(
MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX,
)) ||
activeDropdownFocusId === PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID
) {
return;
}
handleSubmitChanges();
onClickOutside(items, event);
},
listenerId: instanceId,
});
useHotkeysOnFocusedElement({
focusId: instanceId,
keys: [Key.Escape],
callback: handleEscape,
dependencies: [handleEscape],
});
const getItemValueAsString = (index: number): string => {
if (index >= items.length) {
return '';
}
let item;
switch (fieldMetadataType) {
case FieldMetadataType.LINKS:
@@ -110,22 +120,26 @@ export const MultiItemFieldInput = <T,>({
}
};
const shouldAutoEditSingleItem = items.length === 1 && maxItemCount === 1;
const shouldAutoEnterBecauseOnlyOneItemIsAllowed = maxItemCount === 1;
const shouldAutoEditFirstItemOnOpen =
items.length === 0 || maxItemCount === 1;
const [isInputDisplayed, setIsInputDisplayed] = useState(
shouldAutoEditSingleItem,
shouldAutoEditFirstItemOnOpen,
);
const [inputValue, setInputValue] = useState(() =>
shouldAutoEditSingleItem ? getItemValueAsString(0) : '',
);
const [itemToEditIndex, setItemToEditIndex] = useState(
shouldAutoEditSingleItem ? 0 : -1,
const [inputValue, setInputValue] = useState(
shouldAutoEditFirstItemOnOpen ? getItemValueAsString(0) : '',
);
const [itemToEditIndex, setItemToEditIndex] = useState(0);
const [isAddingNewItem, setIsAddingNewItem] = useState(false);
const [errorData, setErrorData] = useState({
isValid: true,
errorMessage: '',
});
const isAddingNewItem = itemToEditIndex === -1;
const isLimitReached =
typeof maxItemCount === 'number' && items.length >= maxItemCount;
@@ -146,23 +160,48 @@ export const MultiItemFieldInput = <T,>({
return;
}
setItemToEditIndex(-1);
setIsAddingNewItem(true);
setInputValue('');
setIsInputDisplayed(true);
};
const handleEditButtonClick = (index: number) => {
setInputValue(getItemValueAsString(index));
setItemToEditIndex(index);
setInputValue(getItemValueAsString(index));
setIsAddingNewItem(false);
setIsInputDisplayed(true);
};
const handleSubmitInput = () => {
const handleAutoEnter = () => {
const sanitizedInput = inputValue.trim();
const newItem = formatInput
? formatInput(sanitizedInput)
: (sanitizedInput as unknown as T);
const updatedItems = isAddingNewItem
? [...items, newItem]
: toSpliced(items, itemToEditIndex, 1, newItem);
onEnter(updatedItems);
};
const handleSubmitChanges = () => {
const sanitizedInput = inputValue.trim();
const newItem = formatInput
? formatInput(sanitizedInput)
: (sanitizedInput as unknown as T);
if (sanitizedInput === '' && isAddingNewItem) {
return;
}
if (sanitizedInput === '' && shouldAutoEnterBecauseOnlyOneItemIsAllowed) {
onEnter([newItem]);
return;
}
if (sanitizedInput === '' && !isAddingNewItem) {
handleDeleteItem(itemToEditIndex);
return;
@@ -177,23 +216,13 @@ export const MultiItemFieldInput = <T,>({
}
}
const newItem = formatInput
? formatInput(sanitizedInput)
: (sanitizedInput as unknown as T);
if (!isAddingNewItem && newItem === items[itemToEditIndex]) {
setIsInputDisplayed(false);
setInputValue('');
return;
}
const updatedItems = isAddingNewItem
? [...items, newItem]
: toSpliced(items, itemToEditIndex, 1, newItem);
onChange(updatedItems);
setIsAddingNewItem(false);
setIsInputDisplayed(false);
setInputValue('');
};
const handleSetPrimaryItem = (index: number) => {
@@ -205,30 +234,43 @@ export const MultiItemFieldInput = <T,>({
const updatedItems = toSpliced(items, index, 1);
onChange(updatedItems);
setIsInputDisplayed(false);
setInputValue('');
setItemToEditIndex(-1);
setIsAddingNewItem(false);
};
const handleEscape = () => {
onEscape(items);
};
useHotkeysOnFocusedElement({
focusId: instanceId,
keys: [Key.Escape],
callback: handleEscape,
dependencies: [handleEscape],
});
return (
<DropdownContent ref={containerRef}>
{!!items.length && (!shouldAutoEditSingleItem || !isInputDisplayed) && (
<>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) =>
renderItem({
value: item,
index,
handleEdit: () => handleEditButtonClick(index),
handleSetPrimary: () => handleSetPrimaryItem(index),
handleDelete: () => handleDeleteItem(index),
}),
)}
</DropdownMenuItemsContainer>
{isInputDisplayed || !isLimitReached ? (
<DropdownMenuSeparator />
) : null}
</>
)}
{!!items.length &&
(!shouldAutoEnterBecauseOnlyOneItemIsAllowed || !isInputDisplayed) && (
<>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) =>
renderItem({
value: item,
index,
handleEdit: () => handleEditButtonClick(index),
handleSetPrimary: () => handleSetPrimaryItem(index),
handleDelete: () => {
handleDeleteItem(index);
},
}),
)}
</DropdownMenuItemsContainer>
{isInputDisplayed || !isLimitReached ? (
<DropdownMenuSeparator />
) : null}
</>
)}
{isInputDisplayed || !items.length ? (
<MultiItemBaseInput
instanceId={instanceId}
@@ -243,13 +285,23 @@ export const MultiItemFieldInput = <T,>({
? handleInputChange(turnIntoEmptyStringIfWhitespacesOnly(value))
: handleInputChange('');
}}
onEnter={handleSubmitInput}
onEnter={() => {
handleSubmitChanges();
if (shouldAutoEnterBecauseOnlyOneItemIsAllowed) {
handleAutoEnter();
}
}}
hasItem={!!items.length}
rightComponent={
items.length ? (
<LightIconButton
Icon={isAddingNewItem ? IconPlus : IconCheck}
onClick={handleSubmitInput}
onClick={() => {
handleSubmitChanges();
if (shouldAutoEnterBecauseOnlyOneItemIsAllowed) {
handleAutoEnter();
}
}}
/>
) : null
}

View File

@@ -51,35 +51,23 @@ export const MultiItemFieldMenuItem = <T,>({
setIsHovered(false);
};
const handleDeleteClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
const handleDeleteClick = () => {
closeDropdown(dropdownId);
setIsHovered(false);
onDelete?.();
};
const handleSetAsPrimaryClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
const handleSetAsPrimaryClick = () => {
closeDropdown(dropdownId);
onSetAsPrimary?.();
};
const handleEditClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
const handleEditClick = () => {
closeDropdown(dropdownId);
onEdit?.();
};
const handleCopyClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
const handleCopyClick = () => {
closeDropdown(dropdownId);
onCopy?.(value);
};

View File

@@ -11,14 +11,18 @@ import 'react-phone-number-input/style.css';
import { MultiItemFieldInput } from './MultiItemFieldInput';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX } from '@/object-record/record-field/ui/meta-types/input/constants/MultiItemFieldInputDropdownClickOutsideId';
import { createPhonesFromFieldValue } from '@/object-record/record-field/ui/meta-types/input/utils/phonesUtils';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import {
type FieldPhonesValue,
type PhoneRecord,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { phonesSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { css } from '@emotion/react';
import { useContext } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
@@ -75,15 +79,13 @@ const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
`;
export const PhonesFieldInput = () => {
const { getLatestDraftValue, fieldDefinition, setDraftValue, draftValue } =
usePhonesField();
const { fieldDefinition, setDraftValue, draftValue } = usePhonesField();
const { onEscape, onClickOutside } = useContext(FieldInputEventContext);
const { onEscape, onClickOutside, onEnter } = useContext(
FieldInputEventContext,
);
const phones = createPhonesFromFieldValue(draftValue);
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldComponentInstanceContext,
);
const defaultCountry = stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
@@ -93,26 +95,26 @@ export const PhonesFieldInput = () => {
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
const handlePhonesChange = (
updatedPhones: {
number: string;
countryCode: string;
callingCode: string;
}[],
) => {
const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones;
const parseArrayToPhonesValue = (phones: PhoneRecord[]) => {
const [nextPrimaryPhone, ...nextAdditionalPhones] = phones;
const newValue = {
const nextValue: FieldPhonesValue = {
primaryPhoneNumber: nextPrimaryPhone?.number ?? '',
primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '',
primaryPhoneCallingCode: nextPrimaryPhone?.callingCode ?? '',
additionalPhones: nextAdditionalPhones,
};
const parseResponse = phonesSchema.safeParse(nextValue);
if (parseResponse.success) {
return parseResponse.data;
}
};
const newValidatedPhoneResponse = phonesSchema.safeParse(newValue);
const handlePhonesChange = (updatedPhones: PhoneRecord[]) => {
const nextValue = parseArrayToPhonesValue(updatedPhones);
if (newValidatedPhoneResponse.success) {
setDraftValue(newValidatedPhoneResponse.data);
if (isDefined(nextValue)) {
setDraftValue(nextValue);
}
};
@@ -134,15 +136,21 @@ export const PhonesFieldInput = () => {
};
const handleClickOutside = (
_newValue: any,
updatedPhones: PhoneRecord[],
event: MouseEvent | TouchEvent,
) => {
const latestDraftValue = getLatestDraftValue(instanceId);
onClickOutside?.({ newValue: latestDraftValue, event });
onClickOutside?.({
newValue: parseArrayToPhonesValue(updatedPhones),
event,
});
};
const handleEscape = (_newValue: any) => {
onEscape?.({ newValue: draftValue });
const handleEscape = (updatedPhones: PhoneRecord[]) => {
onEscape?.({ newValue: parseArrayToPhonesValue(updatedPhones) });
};
const handleEnter = (updatedPhones: PhoneRecord[]) => {
onEnter?.({ newValue: parseArrayToPhonesValue(updatedPhones) });
};
return (
@@ -151,10 +159,19 @@ export const PhonesFieldInput = () => {
onChange={handlePhonesChange}
onClickOutside={handleClickOutside}
onEscape={handleEscape}
onEnter={handleEnter}
placeholder="Phone"
fieldMetadataType={FieldMetadataType.PHONES}
validateInput={validateInput}
formatInput={(input) => {
if (input === '') {
return {
number: '',
callingCode: '',
countryCode: '',
};
}
const phone = parsePhoneNumber(input);
if (phone !== undefined) {
return {
@@ -163,6 +180,7 @@ export const PhonesFieldInput = () => {
countryCode: phone.country as string,
};
}
return {
number: '',
callingCode: '',
@@ -178,7 +196,7 @@ export const PhonesFieldInput = () => {
}) => (
<PhonesFieldMenuItem
key={index}
dropdownId={`phones-field-input-${fieldDefinition.metadata.fieldName}-${index}`}
dropdownId={`${MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX}-${fieldDefinition.metadata.fieldName}-${index}`}
showPrimaryIcon={getShowPrimaryIcon(index)}
showSetAsPrimaryButton={getShowSetAsPrimaryButton(index)}
phone={phone}

View File

@@ -0,0 +1,2 @@
export const MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX =
'multi-item-field-input-dropdown';

View File

@@ -97,6 +97,10 @@ export const useGetRecordFilterDisplayValue = () => {
case RecordFilterOperand.IS_BEFORE: {
const pointInTime = new Date(recordFilter.value);
if (!isValid(pointInTime)) {
return '';
}
const { displayValue } = getDateTimeFilterDisplayValue(pointInTime);
return `${displayValue}`;

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { PhoneCountryPickerDropdownSelect } from './PhoneCountryPickerDropdownSelect';
import { PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID } from '@/ui/input/components/internal/phone/constants/PhoneCountryCodePickerDropdownId';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -77,17 +78,15 @@ export const PhoneCountryPickerDropdownButton = ({
const [selectedCountry, setSelectedCountry] = useState<Country>();
const dropdownId = 'country-picker-dropdown-id';
const isDropdownOpen = useRecoilComponentValue(
isDropdownOpenComponentState,
dropdownId,
PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID,
);
const { closeDropdown } = useCloseDropdown();
const handleChange = (countryCode: string) => {
closeDropdown(dropdownId);
closeDropdown(PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID);
onChange(countryCode);
};
@@ -102,7 +101,7 @@ export const PhoneCountryPickerDropdownButton = ({
return (
<Dropdown
dropdownId="country-picker-dropdown-id"
dropdownId={PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID}
clickableComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledIconContainer>

View File

@@ -0,0 +1,2 @@
export const PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID =
'phone-country-picker-dropdown-id';