feat: add feature to customize onClick behaviour for phone, email and links data type (#16265)
Fixes Issue: #15797 This PR adds a configurable click behavior setting for Phone, Email, and Links field types, allowing users to customize what happens when clicking on these fields. A new option (**Click Behaviour**) to configure the default behaviour for onClick of data is added in the settings page (Settings → Data Model → Object → Field Edit) for Phone, Email and Links data types. Users can now choose between two actions: - **Copy to clipboard**: Copies the value to clipboard with a success toast message - **Open as link**: Opens the value as a link (tel:, mailto:, or http/https) The default behaviour is persisted for all these three types(phone- Copy to clipboard, email & links: open link) to maintain backward compatibility. **Screenshots :** <img width="2084" height="1736" alt="image" src="https://github.com/user-attachments/assets/eb5d129c-e3e0-4334-b426-eb38d8a4840c" /> <img width="1474" height="1428" alt="image" src="https://github.com/user-attachments/assets/487f4e44-6151-4254-bc5c-5398be5fc087" /> <img width="1594" height="1398" alt="image" src="https://github.com/user-attachments/assets/abdbc213-7223-4d57-809e-4d55c90cda80" /> --------- Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
+20
-2
@@ -1,8 +1,26 @@
|
||||
import { useEmailsFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useEmailsFieldDisplay';
|
||||
import { EmailsDisplay } from '@/ui/field/display/components/EmailsDisplay';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
export const EmailsFieldDisplay = () => {
|
||||
const { fieldValue } = useEmailsFieldDisplay();
|
||||
const { fieldValue, fieldDefinition } = useEmailsFieldDisplay();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { t } = useLingui();
|
||||
|
||||
return <EmailsDisplay value={fieldValue} />;
|
||||
const onClickAction = fieldDefinition.metadata.settings?.clickAction;
|
||||
|
||||
const handleEmailClick = (
|
||||
email: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
if (onClickAction === FieldMetadataSettingsOnClickAction.COPY) {
|
||||
event.preventDefault();
|
||||
copyToClipboard(email, t`Email copied to clipboard`);
|
||||
}
|
||||
};
|
||||
|
||||
return <EmailsDisplay value={fieldValue} onEmailClick={handleEmailClick} />;
|
||||
};
|
||||
|
||||
+21
-2
@@ -1,8 +1,27 @@
|
||||
import { useLinksFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useLinksFieldDisplay';
|
||||
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
|
||||
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
export const LinksFieldDisplay = () => {
|
||||
const { fieldValue } = useLinksFieldDisplay();
|
||||
const { fieldValue, fieldDefinition } = useLinksFieldDisplay();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { t } = useLingui();
|
||||
|
||||
return <LinksDisplay value={fieldValue} />;
|
||||
const onClickAction = fieldDefinition.metadata.settings?.clickAction;
|
||||
|
||||
const handleLinkClick = (
|
||||
url: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
if (onClickAction === FieldMetadataSettingsOnClickAction.COPY) {
|
||||
event.preventDefault();
|
||||
copyToClipboard(url, t`Link copied to clipboard`);
|
||||
}
|
||||
};
|
||||
|
||||
return <LinksDisplay value={fieldValue} onLinkClick={handleLinkClick} />;
|
||||
};
|
||||
|
||||
+8
-29
@@ -1,48 +1,27 @@
|
||||
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
|
||||
import { usePhonesFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/usePhonesFieldDisplay';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { PhonesDisplay } from '@/ui/field/display/components/PhonesDisplay';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
export const PhonesFieldDisplay = () => {
|
||||
const { fieldValue } = usePhonesFieldDisplay();
|
||||
|
||||
const { fieldValue, fieldDefinition } = usePhonesFieldDisplay();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { isFocused } = useFieldFocus();
|
||||
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const IconCircleCheck = getIcon('IconCircleCheck');
|
||||
const IconExclamationCircle = getIcon('IconExclamationCircle');
|
||||
const onClickAction = fieldDefinition.metadata.settings?.clickAction;
|
||||
|
||||
const handleClick = async (
|
||||
phoneNumber: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(phoneNumber);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Phone number copied to clipboard`,
|
||||
options: {
|
||||
icon: <IconCircleCheck size={16} color="green" />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Error copying to clipboard`,
|
||||
options: {
|
||||
icon: <IconExclamationCircle size={16} color="red" />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
if (onClickAction === FieldMetadataSettingsOnClickAction.COPY) {
|
||||
event.preventDefault();
|
||||
copyToClipboard(phoneNumber, t`Phone number copied to clipboard`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+4
@@ -2,19 +2,23 @@ import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
import { EmailsFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/EmailsFieldDisplay';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
|
||||
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'UI/Data/Field/Display/EmailsFieldDisplay',
|
||||
decorators: [
|
||||
I18nFrontDecorator,
|
||||
MemoryRouterDecorator,
|
||||
getFieldDecorator('person', 'emails', {
|
||||
primaryEmail: 'test@test.com',
|
||||
additionalEmails: ['toto@test.com'],
|
||||
}),
|
||||
ComponentDecorator,
|
||||
SnackBarDecorator,
|
||||
],
|
||||
component: EmailsFieldDisplay,
|
||||
args: {},
|
||||
|
||||
+4
@@ -5,7 +5,9 @@ import { FieldFocusContext } from '@/object-record/record-field/ui/contexts/Fiel
|
||||
import { FieldFocusContextProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider';
|
||||
import { LinksFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/LinksFieldDisplay';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
|
||||
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
|
||||
|
||||
@@ -22,6 +24,7 @@ const FieldFocusEffect = () => {
|
||||
const meta: Meta = {
|
||||
title: 'UI/Data/Field/Display/LinksFieldDisplay',
|
||||
decorators: [
|
||||
I18nFrontDecorator,
|
||||
MemoryRouterDecorator,
|
||||
getFieldDecorator('company', 'domainName', {
|
||||
primaryLinkUrl: 'https://www.google.com',
|
||||
@@ -29,6 +32,7 @@ const meta: Meta = {
|
||||
secondaryLinks: ['https://www.toto.com'],
|
||||
}),
|
||||
ComponentDecorator,
|
||||
SnackBarDecorator,
|
||||
],
|
||||
component: LinksFieldDisplay,
|
||||
args: {},
|
||||
|
||||
+5
@@ -1,12 +1,17 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
|
||||
import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFieldEmails';
|
||||
import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { FieldContext } from '../../contexts/FieldContext';
|
||||
|
||||
export const useEmailsFieldDisplay = () => {
|
||||
const { recordId, fieldDefinition } = useContext(FieldContext);
|
||||
|
||||
assertFieldMetadata(FieldMetadataType.EMAILS, isFieldEmails, fieldDefinition);
|
||||
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const fieldValue = useRecordFieldValue<FieldEmailsValue | undefined>(
|
||||
|
||||
+5
@@ -2,12 +2,17 @@ import { useContext } from 'react';
|
||||
|
||||
import { type FieldLinksValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
|
||||
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
|
||||
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
|
||||
import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { FieldContext } from '../../contexts/FieldContext';
|
||||
|
||||
export const useLinksFieldDisplay = () => {
|
||||
const { recordId, fieldDefinition } = useContext(FieldContext);
|
||||
|
||||
assertFieldMetadata(FieldMetadataType.LINKS, isFieldLinks, fieldDefinition);
|
||||
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const fieldValue = useRecordFieldValue<FieldLinksValue | undefined>(
|
||||
|
||||
+5
@@ -2,12 +2,17 @@ import { useContext } from 'react';
|
||||
|
||||
import { type FieldPhonesValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
|
||||
import { assertFieldMetadata } from '@/object-record/record-field/ui/types/guards/assertFieldMetadata';
|
||||
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
|
||||
import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { FieldContext } from '../../contexts/FieldContext';
|
||||
|
||||
export const usePhonesFieldDisplay = () => {
|
||||
const { recordId, fieldDefinition } = useContext(FieldContext);
|
||||
|
||||
assertFieldMetadata(FieldMetadataType.PHONES, isFieldPhones, fieldDefinition);
|
||||
|
||||
const fieldName = fieldDefinition.metadata.fieldName;
|
||||
|
||||
const fieldValue = useRecordFieldValue<FieldPhonesValue | undefined>(
|
||||
|
||||
-2
@@ -2,7 +2,6 @@ import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { canBeUnique } from '@/settings/data-model/fields/forms/utils/canBeUnique.util';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -66,7 +65,6 @@ export const SettingsDataModelFieldIsUniqueForm = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconKey}
|
||||
title={t`Unique`}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { getSettingsDataModelFieldOnClickActionDescription } from '@/settings/data-model/fields/forms/utils/getSettingsDataModelFieldOnClickActionDescription.util';
|
||||
import { type SettingsDataModelFieldOnClickActionFormValues } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldOnClickActionSchema';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
type FieldMetadataMultiItemSettings,
|
||||
FieldMetadataSettingsOnClickAction,
|
||||
type FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
import { IconClick } from 'twenty-ui/display';
|
||||
|
||||
type SettingsDataModelFieldOnClickActionFormProps = {
|
||||
disabled?: boolean;
|
||||
existingFieldMetadataId: string;
|
||||
fieldType:
|
||||
| FieldMetadataType.PHONES
|
||||
| FieldMetadataType.EMAILS
|
||||
| FieldMetadataType.LINKS;
|
||||
};
|
||||
|
||||
export const SettingsDataModelFieldOnClickActionForm = ({
|
||||
disabled,
|
||||
existingFieldMetadataId,
|
||||
fieldType,
|
||||
}: SettingsDataModelFieldOnClickActionFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { control } =
|
||||
useFormContext<SettingsDataModelFieldOnClickActionFormValues>();
|
||||
|
||||
const { fieldMetadataItem } = useFieldMetadataItemById(
|
||||
existingFieldMetadataId,
|
||||
);
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: t`Open as link`,
|
||||
value: FieldMetadataSettingsOnClickAction.OPEN_LINK,
|
||||
},
|
||||
{
|
||||
label: t`Copy to clipboard`,
|
||||
value: FieldMetadataSettingsOnClickAction.COPY,
|
||||
},
|
||||
];
|
||||
|
||||
const description =
|
||||
getSettingsDataModelFieldOnClickActionDescription(fieldType);
|
||||
|
||||
const existingSettings =
|
||||
(fieldMetadataItem?.settings as FieldMetadataMultiItemSettings) ?? {};
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="settings"
|
||||
control={control}
|
||||
defaultValue={{
|
||||
...existingSettings,
|
||||
clickAction:
|
||||
existingSettings.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_LINK,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const currentSettings =
|
||||
(value as FieldMetadataMultiItemSettings | undefined) ?? {};
|
||||
|
||||
const clickAction =
|
||||
currentSettings.clickAction ??
|
||||
FieldMetadataSettingsOnClickAction.OPEN_LINK;
|
||||
|
||||
return (
|
||||
<SettingsOptionCardContentSelect
|
||||
Icon={IconClick}
|
||||
title={t`Action on click`}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Select<FieldMetadataSettingsOnClickAction>
|
||||
dropdownWidth={180}
|
||||
value={clickAction}
|
||||
onChange={(newValue) =>
|
||||
onChange({
|
||||
...currentSettings,
|
||||
clickAction: newValue,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
dropdownId="field-click-behavior-select"
|
||||
options={options}
|
||||
selectSizeVariant="small"
|
||||
withSearchInput={false}
|
||||
/>
|
||||
</SettingsOptionCardContentSelect>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+33
-4
@@ -25,11 +25,14 @@ import {
|
||||
settingsDataModelFieldSelectFormSchema,
|
||||
} from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
|
||||
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectSettingsFormCard';
|
||||
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
|
||||
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
|
||||
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsDataModelFieldOnClickActionForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm';
|
||||
import { SettingsDataModelFieldRelationFormCard } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationFormCard';
|
||||
import { mergeSettingsSchemas } from '@/settings/data-model/fields/forms/utils/mergeSettingsSchema.util';
|
||||
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
|
||||
import { settingsDataModelFieldOnClickActionSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldOnClickActionSchema';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
|
||||
@@ -91,17 +94,27 @@ const phonesFieldFormSchema = z
|
||||
|
||||
const emailsFieldFormSchema = z
|
||||
.object({ type: z.literal(FieldMetadataType.EMAILS) })
|
||||
.extend(settingsDataModelFieldMaxValuesSchema.shape)
|
||||
.merge(
|
||||
mergeSettingsSchemas(
|
||||
settingsDataModelFieldMaxValuesSchema,
|
||||
settingsDataModelFieldOnClickActionSchema,
|
||||
),
|
||||
)
|
||||
.extend(isUniqueFieldFormSchema.shape);
|
||||
|
||||
const linksFieldFormSchema = z
|
||||
.object({ type: z.literal(FieldMetadataType.LINKS) })
|
||||
.extend(settingsDataModelFieldMaxValuesSchema.shape)
|
||||
.merge(
|
||||
mergeSettingsSchemas(
|
||||
settingsDataModelFieldMaxValuesSchema,
|
||||
settingsDataModelFieldOnClickActionSchema,
|
||||
),
|
||||
)
|
||||
.extend(isUniqueFieldFormSchema.shape);
|
||||
|
||||
const arrayFieldFormSchema = z
|
||||
.object({ type: z.literal(FieldMetadataType.ARRAY) })
|
||||
.extend(settingsDataModelFieldMaxValuesSchema.shape)
|
||||
.merge(mergeSettingsSchemas(settingsDataModelFieldMaxValuesSchema))
|
||||
.extend(isUniqueFieldFormSchema.shape);
|
||||
|
||||
const otherFieldsFormSchema = z
|
||||
@@ -322,6 +335,22 @@ export const SettingsDataModelFieldSettingsFormCard = ({
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
{[FieldMetadataType.EMAILS, FieldMetadataType.LINKS].includes(
|
||||
fieldType,
|
||||
) && (
|
||||
<>
|
||||
<SettingsDataModelFieldOnClickActionForm
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
fieldType={
|
||||
fieldType as
|
||||
| FieldMetadataType.EMAILS
|
||||
| FieldMetadataType.LINKS
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<SettingsDataModelFieldIsUniqueForm
|
||||
fieldType={fieldType}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
|
||||
+2
@@ -1,3 +1,4 @@
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||
|
||||
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
|
||||
@@ -38,6 +39,7 @@ export const SettingsDataModelFieldTextSettingsFormCard = ({
|
||||
disabled={disabled}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
/>
|
||||
<Separator />
|
||||
<SettingsDataModelFieldIsUniqueForm
|
||||
fieldType={FieldMetadataType.TEXT}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export const SettingsDataModelFieldDateSettingsFormCard = ({
|
||||
disabled={disabled}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
/>
|
||||
<Separator />
|
||||
<SettingsDataModelFieldIsUniqueForm
|
||||
fieldType={fieldType}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
|
||||
import { SettingsDataModelFieldNumberForm } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
|
||||
@@ -37,7 +38,7 @@ export const SettingsDataModelFieldNumberSettingsFormCard = ({
|
||||
disabled={disabled}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<SettingsDataModelFieldIsUniqueForm
|
||||
fieldType={FieldMetadataType.NUMBER}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
|
||||
+9
-2
@@ -3,8 +3,10 @@ import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
|
||||
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
|
||||
import { mergeSettingsSchemas } from '@/settings/data-model/fields/forms/utils/mergeSettingsSchema.util';
|
||||
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
|
||||
import { settingsDataModelFieldOnClickActionSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldOnClickActionSchema';
|
||||
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -28,7 +30,12 @@ export const settingsDataModelFieldPhonesFormSchema = z
|
||||
.object({
|
||||
defaultValue: phonesFieldDefaultValueSchema,
|
||||
})
|
||||
.merge(settingsDataModelFieldMaxValuesSchema);
|
||||
.merge(
|
||||
mergeSettingsSchemas(
|
||||
settingsDataModelFieldMaxValuesSchema,
|
||||
settingsDataModelFieldOnClickActionSchema,
|
||||
),
|
||||
);
|
||||
|
||||
export type SettingsDataModelFieldPhonesFormValues = z.infer<
|
||||
typeof settingsDataModelFieldPhonesFormSchema
|
||||
|
||||
+8
-1
@@ -1,13 +1,14 @@
|
||||
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
|
||||
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
|
||||
import { SettingsDataModelFieldMaxValuesForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm';
|
||||
import { SettingsDataModelFieldOnClickActionForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldOnClickActionForm';
|
||||
import {
|
||||
SettingsDataModelFieldPhonesForm,
|
||||
type SettingsDataModelFieldPhonesFormValues,
|
||||
} from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
|
||||
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
|
||||
import { Separator } from '@/settings/components/Separator';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
|
||||
@@ -54,6 +55,12 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
|
||||
fieldType={FieldMetadataType.PHONES}
|
||||
/>
|
||||
<Separator />
|
||||
<SettingsDataModelFieldOnClickActionForm
|
||||
disabled={disabled}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
fieldType={FieldMetadataType.PHONES}
|
||||
/>
|
||||
<Separator />
|
||||
<SettingsDataModelFieldIsUniqueForm
|
||||
fieldType={FieldMetadataType.PHONES}
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
export const getSettingsDataModelFieldOnClickActionDescription = (
|
||||
fieldType:
|
||||
| FieldMetadataType.PHONES
|
||||
| FieldMetadataType.EMAILS
|
||||
| FieldMetadataType.LINKS,
|
||||
) => {
|
||||
switch (fieldType) {
|
||||
case FieldMetadataType.PHONES:
|
||||
return t`Choose what happens when you click a phone number`;
|
||||
case FieldMetadataType.EMAILS:
|
||||
return t`Choose what happens when you click an email`;
|
||||
case FieldMetadataType.LINKS:
|
||||
return t`Choose what happens when you click a link`;
|
||||
default:
|
||||
assertUnreachable(fieldType, `Invalid field type: ${fieldType}`);
|
||||
}
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import z from 'zod';
|
||||
|
||||
export const mergeSettingsSchemas = (
|
||||
...schemas: z.ZodObject<{ settings: z.ZodObject<any> }>[]
|
||||
) => {
|
||||
return z.object({
|
||||
settings: schemas.reduce(
|
||||
(acc, schema) => acc.merge(schema.shape.settings),
|
||||
z.object({}) as z.ZodObject<any>,
|
||||
),
|
||||
});
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const settingsDataModelFieldOnClickActionSchema = z.object({
|
||||
settings: z.object({
|
||||
clickAction: z.enum(['COPY', 'OPEN_LINK']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SettingsDataModelFieldOnClickActionFormValues = z.infer<
|
||||
typeof settingsDataModelFieldOnClickActionSchema
|
||||
>;
|
||||
+2
@@ -4,6 +4,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
@@ -25,6 +26,7 @@ const meta: Meta<typeof SettingsDataModelFieldPreviewWidget> = {
|
||||
'Modules/Settings/DataModel/Fields/Preview/SettingsDataModelFieldPreviewWidget',
|
||||
component: SettingsDataModelFieldPreviewWidget,
|
||||
decorators: [
|
||||
I18nFrontDecorator,
|
||||
MemoryRouterDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { type FieldEmailsValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
|
||||
@@ -10,6 +10,7 @@ import { THEME_COMMON } from 'twenty-ui/theme';
|
||||
type EmailsDisplayProps = {
|
||||
value?: FieldEmailsValue;
|
||||
isFocused?: boolean;
|
||||
onEmailClick?: (email: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const themeSpacing = THEME_COMMON.spacingMultiplicator;
|
||||
@@ -27,7 +28,11 @@ const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const EmailsDisplay = ({ value, isFocused }: EmailsDisplayProps) => {
|
||||
export const EmailsDisplay = ({
|
||||
value,
|
||||
isFocused,
|
||||
onEmailClick,
|
||||
}: EmailsDisplayProps) => {
|
||||
const emails = useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -40,13 +45,23 @@ export const EmailsDisplay = ({ value, isFocused }: EmailsDisplayProps) => {
|
||||
return isFocused ? (
|
||||
<ExpandableList isChipCountDisplayed>
|
||||
{emails.map((email, index) => (
|
||||
<RoundedLink key={index} label={email} href={`mailto:${email}`} />
|
||||
<RoundedLink
|
||||
key={index}
|
||||
label={email}
|
||||
href={`mailto:${email}`}
|
||||
onClick={(event) => onEmailClick?.(email, event)}
|
||||
/>
|
||||
))}
|
||||
</ExpandableList>
|
||||
) : (
|
||||
<StyledContainer>
|
||||
{emails.map((email, index) => (
|
||||
<RoundedLink key={index} label={email} href={`mailto:${email}`} />
|
||||
<RoundedLink
|
||||
key={index}
|
||||
label={email}
|
||||
href={`mailto:${email}`}
|
||||
onClick={(event) => onEmailClick?.(email, event)}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { getFieldLinkDefinedLinks } from '@/object-record/record-field/ui/meta-types/input/utils/getFieldLinkDefinedLinks';
|
||||
import { type FieldLinksValue } from '@/object-record/record-field/ui/types/FieldMetadata';
|
||||
@@ -13,9 +13,10 @@ import { checkUrlType } from '~/utils/checkUrlType';
|
||||
|
||||
type LinksDisplayProps = {
|
||||
value?: FieldLinksValue;
|
||||
onLinkClick?: (url: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||
export const LinksDisplay = ({ value, onLinkClick }: LinksDisplayProps) => {
|
||||
const links = useMemo(() => {
|
||||
if (!isDefined(value)) {
|
||||
return [];
|
||||
@@ -43,9 +44,20 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||
<ExpandableList>
|
||||
{links.map(({ url, label, type }, index) =>
|
||||
type === LinkType.LinkedIn || type === LinkType.Twitter ? (
|
||||
<SocialLink key={index} href={url} type={type} label={label} />
|
||||
<SocialLink
|
||||
key={index}
|
||||
href={url}
|
||||
type={type}
|
||||
label={label}
|
||||
onClick={(event) => onLinkClick?.(url, event)}
|
||||
/>
|
||||
) : (
|
||||
<RoundedLink key={index} href={url} label={label} />
|
||||
<RoundedLink
|
||||
key={index}
|
||||
href={url}
|
||||
label={label}
|
||||
onClick={(event) => onLinkClick?.(url, event)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ExpandableList>
|
||||
|
||||
@@ -75,13 +75,6 @@ export const PhonesDisplay = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (
|
||||
number: string,
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
) => {
|
||||
onPhoneNumberClick?.(number, event);
|
||||
};
|
||||
|
||||
return isFocused ? (
|
||||
<ExpandableList isChipCountDisplayed>
|
||||
{phones.map(({ number, callingCode }, index) => {
|
||||
@@ -95,7 +88,9 @@ export const PhonesDisplay = ({
|
||||
label={
|
||||
parsedPhone ? parsedPhone.formatInternational() : invalidPhone
|
||||
}
|
||||
onClick={(event) => handleClick(callingCode + number, event)}
|
||||
onClick={(event) =>
|
||||
onPhoneNumberClick?.(callingCode + number, event)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -113,7 +108,9 @@ export const PhonesDisplay = ({
|
||||
label={
|
||||
parsedPhone ? parsedPhone.formatInternational() : invalidPhone
|
||||
}
|
||||
onClick={(event) => handleClick(callingCode + number, event)}
|
||||
onClick={(event) =>
|
||||
onPhoneNumberClick?.(callingCode + number, event)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -119,6 +119,7 @@ export const SettingsObjectFieldEdit = () => {
|
||||
label: fieldMetadataItem?.label ?? '',
|
||||
description: fieldMetadataItem?.description,
|
||||
isLabelSyncedWithName: fieldMetadataItem?.isLabelSyncedWithName ?? true,
|
||||
settings: fieldMetadataItem?.settings,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export enum FieldMetadataSettingsOnClickAction {
|
||||
COPY = 'COPY',
|
||||
OPEN_LINK = 'OPEN_LINK',
|
||||
}
|
||||
|
||||
export type FieldMetadataMultiItemSettings = {
|
||||
maxNumberOfValues?: number;
|
||||
clickAction?: FieldMetadataSettingsOnClickAction;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,7 @@ export {
|
||||
FieldMetadataDefaultArray,
|
||||
} from './FieldMetadataDefaultValue';
|
||||
export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings';
|
||||
export { FieldMetadataSettingsOnClickAction } from './FieldMetadataMultiItemSettings';
|
||||
export type { TagColor, FieldMetadataOptions } from './FieldMetadataOptions';
|
||||
export {
|
||||
FieldMetadataDefaultOption,
|
||||
|
||||
Reference in New Issue
Block a user