mirror of
https://github.com/twentyhq/twenty.git
synced 2026-04-19 22:39:30 -04:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MULTI_ITEM_FIELD_INPUT_DROPDOWN_ID_PREFIX =
|
||||
'multi-item-field-input-dropdown';
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const PHONE_COUNTRY_CODE_PICKER_DROPDOWN_ID =
|
||||
'phone-country-picker-dropdown-id';
|
||||
Reference in New Issue
Block a user