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:
Abhishek Kumar
2025-12-12 19:04:47 +05:30
committed by GitHub
parent 4f91b48470
commit 8dbcd506ed
26 changed files with 323 additions and 60 deletions
@@ -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`}
@@ -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>
);
}}
/>
);
};
@@ -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}
@@ -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}
@@ -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}
@@ -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}
@@ -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
@@ -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}
@@ -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}`);
}
};
@@ -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>,
),
});
};
@@ -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
>;
@@ -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,