diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index a89b5180f..8cc496114 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -305,6 +305,7 @@ export enum FieldMetadataType {
Email = 'EMAIL',
FullName = 'FULL_NAME',
Link = 'LINK',
+ Links = 'LINKS',
MultiSelect = 'MULTI_SELECT',
Number = 'NUMBER',
Numeric = 'NUMERIC',
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index 0a5136f56..82c3f360a 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -212,6 +212,7 @@ export enum FieldMetadataType {
Email = 'EMAIL',
FullName = 'FULL_NAME',
Link = 'LINK',
+ Links = 'LINKS',
MultiSelect = 'MULTI_SELECT',
Number = 'NUMBER',
Numeric = 'NUMERIC',
diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx
index a63a5810a..3c8fee324 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx
@@ -1,5 +1,8 @@
import { useContext } from 'react';
+import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
+import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
+
import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';
@@ -62,6 +65,8 @@ export const FieldDisplay = () => {
) : isFieldLink(fieldDefinition) ? (
+ ) : isFieldLinks(fieldDefinition) ? (
+
) : isFieldCurrency(fieldDefinition) ? (
) : isFieldFullName(fieldDefinition) ? (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx
index 7a751a330..54a59fd57 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx
@@ -3,12 +3,14 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
+import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
+import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
@@ -131,6 +133,14 @@ export const FieldInput = ({
onTab={onTab}
onShiftTab={onShiftTab}
/>
+ ) : isFieldLinks(fieldDefinition) ? (
+
) : isFieldCurrency(fieldDefinition) ? (
{
const fieldIsLink =
isFieldLink(fieldDefinition) && isFieldLinkValue(valueToPersist);
+ const fieldIsLinks =
+ isFieldLinks(fieldDefinition) && isFieldLinksValue(valueToPersist);
+
const fieldIsBoolean =
isFieldBoolean(fieldDefinition) &&
isFieldBooleanValue(valueToPersist);
@@ -116,6 +121,7 @@ export const usePersistField = () => {
fieldIsDate ||
fieldIsPhone ||
fieldIsLink ||
+ fieldIsLinks ||
fieldIsCurrency ||
fieldIsFullName ||
fieldIsSelect ||
@@ -123,7 +129,7 @@ export const usePersistField = () => {
fieldIsAddress ||
fieldIsRawJson;
- if (isValuePersistable === true) {
+ if (isValuePersistable) {
const fieldName = fieldDefinition.metadata.fieldName;
set(
recordStoreFamilySelector({ recordId: entityId, fieldName }),
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx
new file mode 100644
index 000000000..ac31aac6d
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/LinksFieldDisplay.tsx
@@ -0,0 +1,8 @@
+import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
+import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
+
+export const LinksFieldDisplay = () => {
+ const { fieldValue } = useLinksField();
+
+ return ;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts
new file mode 100644
index 000000000..9cec700b9
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts
@@ -0,0 +1,53 @@
+import { useContext } from 'react';
+import { useRecoilState, useRecoilValue } from 'recoil';
+
+import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
+import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
+import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
+import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
+import { linksSchema } from '@/object-record/record-field/types/guards/isFieldLinksValue';
+import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+
+import { FieldContext } from '../../contexts/FieldContext';
+import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
+
+export const useLinksField = () => {
+ const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
+
+ assertFieldMetadata(FieldMetadataType.Links, isFieldLinks, fieldDefinition);
+
+ const fieldName = fieldDefinition.metadata.fieldName;
+
+ const [fieldValue, setFieldValue] = useRecoilState(
+ recordStoreFamilySelector({
+ recordId: entityId,
+ fieldName: fieldName,
+ }),
+ );
+
+ const { setDraftValue, getDraftValueSelector } =
+ useRecordFieldInput(`${entityId}-${fieldName}`);
+
+ const draftValue = useRecoilValue(getDraftValueSelector());
+
+ const persistField = usePersistField();
+
+ const persistLinksField = (nextValue: FieldLinksValue) => {
+ try {
+ persistField(linksSchema.parse(nextValue));
+ } catch {
+ return;
+ }
+ };
+
+ return {
+ fieldDefinition,
+ fieldValue,
+ draftValue,
+ setDraftValue,
+ setFieldValue,
+ hotkeyScope,
+ persistLinksField,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx
new file mode 100644
index 000000000..0f4a449da
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx
@@ -0,0 +1,93 @@
+import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
+import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay';
+import { TextInput } from '@/ui/field/input/components/TextInput';
+
+import { FieldInputEvent } from './DateTimeFieldInput';
+
+export type LinksFieldInputProps = {
+ onClickOutside?: FieldInputEvent;
+ onEnter?: FieldInputEvent;
+ onEscape?: FieldInputEvent;
+ onTab?: FieldInputEvent;
+ onShiftTab?: FieldInputEvent;
+};
+
+export const LinksFieldInput = ({
+ onEnter,
+ onEscape,
+ onClickOutside,
+ onTab,
+ onShiftTab,
+}: LinksFieldInputProps) => {
+ const { draftValue, setDraftValue, hotkeyScope, persistLinksField } =
+ useLinksField();
+
+ const handleEnter = (url: string) => {
+ onEnter?.(() =>
+ persistLinksField({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ }),
+ );
+ };
+
+ const handleEscape = (url: string) => {
+ onEscape?.(() =>
+ persistLinksField({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ }),
+ );
+ };
+
+ const handleClickOutside = (event: MouseEvent | TouchEvent, url: string) => {
+ onClickOutside?.(() =>
+ persistLinksField({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ }),
+ );
+ };
+
+ const handleTab = (url: string) => {
+ onTab?.(() =>
+ persistLinksField({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ }),
+ );
+ };
+
+ const handleShiftTab = (url: string) => {
+ onShiftTab?.(() =>
+ persistLinksField({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ }),
+ );
+ };
+
+ const handleChange = (url: string) => {
+ setDraftValue({
+ primaryLinkUrl: url,
+ primaryLinkLabel: '',
+ });
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts
index 103bb49db..c4a60d98d 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts
@@ -6,6 +6,7 @@ import {
FieldDateTimeValue,
FieldEmailValue,
FieldFullNameValue,
+ FieldLinksValue,
FieldLinkValue,
FieldMultiSelectValue,
FieldNumberValue,
@@ -26,6 +27,11 @@ export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string;
export type FieldLinkDraftValue = { url: string; label: string };
+export type FieldLinksDraftValue = {
+ primaryLinkLabel: string;
+ primaryLinkUrl: string;
+ secondaryLinks?: string | null;
+};
export type FieldCurrencyDraftValue = {
currencyCode: CurrencyCode;
amount: string;
@@ -58,18 +64,20 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue
? FieldEmailDraftValue
: FieldValue extends FieldLinkValue
? FieldLinkDraftValue
- : FieldValue extends FieldCurrencyValue
- ? FieldCurrencyDraftValue
- : FieldValue extends FieldFullNameValue
- ? FieldFullNameDraftValue
- : FieldValue extends FieldRatingValue
- ? FieldRatingValue
- : FieldValue extends FieldSelectValue
- ? FieldSelectDraftValue
- : FieldValue extends FieldMultiSelectValue
- ? FieldMultiSelectDraftValue
- : FieldValue extends FieldRelationValue
- ? FieldRelationDraftValue
- : FieldValue extends FieldAddressValue
- ? FieldAddressDraftValue
- : never;
+ : FieldValue extends FieldLinksValue
+ ? FieldLinksDraftValue
+ : FieldValue extends FieldCurrencyValue
+ ? FieldCurrencyDraftValue
+ : FieldValue extends FieldFullNameValue
+ ? FieldFullNameDraftValue
+ : FieldValue extends FieldRatingValue
+ ? FieldRatingValue
+ : FieldValue extends FieldSelectValue
+ ? FieldSelectDraftValue
+ : FieldValue extends FieldMultiSelectValue
+ ? FieldMultiSelectDraftValue
+ : FieldValue extends FieldRelationValue
+ ? FieldRelationDraftValue
+ : FieldValue extends FieldAddressValue
+ ? FieldAddressDraftValue
+ : never;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts
index a4ad442ca..2a4d1d5f8 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts
@@ -45,6 +45,11 @@ export type FieldLinkMetadata = {
fieldName: string;
};
+export type FieldLinksMetadata = {
+ objectMetadataNameSingular?: string;
+ fieldName: string;
+};
+
export type FieldCurrencyMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
@@ -143,6 +148,11 @@ export type FieldBooleanValue = boolean;
export type FieldPhoneValue = string;
export type FieldEmailValue = string;
export type FieldLinkValue = { url: string; label: string };
+export type FieldLinksValue = {
+ primaryLinkLabel: string;
+ primaryLinkUrl: string;
+ secondaryLinks?: string | null;
+};
export type FieldCurrencyValue = {
currencyCode: CurrencyCode;
amountMicros: number | null;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts
index 614a9d838..9d1f87828 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts
@@ -10,6 +10,7 @@ import {
FieldEmailMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
+ FieldLinksMetadata,
FieldMetadata,
FieldMultiSelectMetadata,
FieldNumberMetadata,
@@ -44,23 +45,25 @@ type AssertFieldMetadataFunction = <
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
- : E extends 'NUMBER'
- ? FieldNumberMetadata
- : E extends 'PHONE'
- ? FieldPhoneMetadata
- : E extends 'PROBABILITY'
- ? FieldRatingMetadata
- : E extends 'RELATION'
- ? FieldRelationMetadata
- : E extends 'TEXT'
- ? FieldTextMetadata
- : E extends 'UUID'
- ? FieldUuidMetadata
- : E extends 'ADDRESS'
- ? FieldAddressMetadata
- : E extends 'RAW_JSON'
- ? FieldRawJsonMetadata
- : never,
+ : E extends 'LINKS'
+ ? FieldLinksMetadata
+ : E extends 'NUMBER'
+ ? FieldNumberMetadata
+ : E extends 'PHONE'
+ ? FieldPhoneMetadata
+ : E extends 'PROBABILITY'
+ ? FieldRatingMetadata
+ : E extends 'RELATION'
+ ? FieldRelationMetadata
+ : E extends 'TEXT'
+ ? FieldTextMetadata
+ : E extends 'UUID'
+ ? FieldUuidMetadata
+ : E extends 'ADDRESS'
+ ? FieldAddressMetadata
+ : E extends 'RAW_JSON'
+ ? FieldRawJsonMetadata
+ : never,
>(
fieldType: E,
fieldTypeGuard: (
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinkValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinkValue.ts
index 63ef67761..b075dc746 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinkValue.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinkValue.ts
@@ -7,7 +7,6 @@ const linkSchema = z.object({
label: z.string(),
});
-// TODO: add zod
export const isFieldLinkValue = (
fieldValue: unknown,
): fieldValue is FieldLinkValue => linkSchema.safeParse(fieldValue).success;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinks.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinks.ts
new file mode 100644
index 000000000..22a50f529
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinks.ts
@@ -0,0 +1,9 @@
+import { FieldMetadataType } from '~/generated-metadata/graphql';
+
+import { FieldDefinition } from '../FieldDefinition';
+import { FieldLinksMetadata, FieldMetadata } from '../FieldMetadata';
+
+export const isFieldLinks = (
+ field: Pick, 'type'>,
+): field is FieldDefinition =>
+ field.type === FieldMetadataType.Links;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts
new file mode 100644
index 000000000..c2d8f2db4
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLinksValue.ts
@@ -0,0 +1,15 @@
+import { z } from 'zod';
+
+import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
+
+import { FieldLinksValue } from '../FieldMetadata';
+
+export const linksSchema = z.object({
+ primaryLinkLabel: z.string(),
+ primaryLinkUrl: absoluteUrlSchema,
+ secondaryLinks: z.string().optional().nullable(),
+}) satisfies z.ZodType;
+
+export const isFieldLinksValue = (
+ fieldValue: unknown,
+): fieldValue is FieldLinksValue => linksSchema.safeParse(fieldValue).success;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts
index dd1573212..ce9c5c77b 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts
@@ -11,6 +11,8 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
+import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
+import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
@@ -95,6 +97,12 @@ export const isFieldValueEmpty = ({
);
}
+ if (isFieldLinks(fieldDefinition)) {
+ return (
+ !isFieldLinksValue(fieldValue) || isValueEmpty(fieldValue.primaryLinkUrl)
+ );
+ }
+
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);
diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
index 95c05b0d0..7101d4dee 100644
--- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
+++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts
@@ -1,7 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
-import { FieldMetadataType } from '~/generated/graphql';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
export const generateEmptyFieldValue = (
fieldMetadataItem: FieldMetadataItem,
@@ -18,6 +18,9 @@ export const generateEmptyFieldValue = (
url: '',
};
}
+ case FieldMetadataType.Links: {
+ return { primaryLinkUrl: '', primaryLinkLabel: '' };
+ }
case FieldMetadataType.FullName: {
return {
firstName: '',
diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeLinkRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeLinkRecordInput.ts
deleted file mode 100644
index 5546f5c34..000000000
--- a/packages/twenty-front/src/modules/object-record/utils/sanitizeLinkRecordInput.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { z } from 'zod';
-
-export const sanitizeLink = (url: string) =>
- getUrlHostName(url) || getUrlHostName(`https://${url}`);
-
-const getUrlHostName = (url: string) => {
- const urlSchema = z.string().url();
- const validation = urlSchema.safeParse(url);
-
- return validation.success
- ? new URL(validation.data).hostname.replace(/^www\./i, '')
- : '';
-};
diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
index 9239f9f16..57c0d193c 100644
--- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
+++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts
@@ -4,9 +4,9 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
-import { sanitizeLink } from '@/object-record/utils/sanitizeLinkRecordInput';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
+import { getUrlHostName } from '~/utils/url/getUrlHostName';
export const sanitizeRecordInput = ({
objectMetadataItem,
@@ -54,6 +54,6 @@ export const sanitizeRecordInput = ({
return {
...filteredResultRecord,
- domainName: sanitizeLink(filteredResultRecord.domainName),
+ domainName: getUrlHostName(filteredResultRecord.domainName),
};
};
diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts
index a867f7cb1..417af6ae5 100644
--- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts
+++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts
@@ -62,6 +62,11 @@ export const SETTINGS_FIELD_TYPE_CONFIGS: Record<
Icon: IconLink,
defaultValue: { url: 'www.twenty.com', label: '' },
},
+ [FieldMetadataType.Links]: {
+ label: 'Links',
+ Icon: IconLink,
+ defaultValue: { primaryLinkUrl: 'twenty.com', primaryLinkLabel: '' },
+ },
[FieldMetadataType.Boolean]: {
label: 'True/False',
Icon: IconCheck,
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
index 68c4b44f5..72c339ff1 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
@@ -67,6 +67,7 @@ const previewableTypes = [
FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Link,
+ FieldMetadataType.Links,
FieldMetadataType.Number,
FieldMetadataType.Rating,
FieldMetadataType.Relation,
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx
new file mode 100644
index 000000000..1c6b1d1a2
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/field/display/components/LinksDisplay.tsx
@@ -0,0 +1,14 @@
+import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
+import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
+import { getUrlHostName } from '~/utils/url/getUrlHostName';
+
+type LinksDisplayProps = {
+ value?: FieldLinksValue;
+};
+
+export const LinksDisplay = ({ value }: LinksDisplayProps) => {
+ const url = value?.primaryLinkUrl || '';
+ const label = value?.primaryLinkLabel || getUrlHostName(url);
+
+ return ;
+};
diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx
index 65ddf9534..ab106a405 100644
--- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx
+++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx
@@ -282,6 +282,7 @@ export const SettingsObjectNewFieldStep2 = () => {
FieldMetadataType.Email,
FieldMetadataType.FullName,
FieldMetadataType.Link,
+ FieldMetadataType.Links,
FieldMetadataType.Numeric,
FieldMetadataType.Phone,
FieldMetadataType.Probability,
diff --git a/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts b/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts
new file mode 100644
index 000000000..f715b5cbd
--- /dev/null
+++ b/packages/twenty-front/src/utils/url/__tests__/getUrlHostName.test.ts
@@ -0,0 +1,25 @@
+import { getUrlHostName } from '~/utils/url/getUrlHostName';
+
+describe('getUrlHostName', () => {
+ it("returns the URL's hostname", () => {
+ expect(getUrlHostName('https://www.example.com')).toBe('example.com');
+ expect(getUrlHostName('http://subdomain.example.com')).toBe(
+ 'subdomain.example.com',
+ );
+ expect(getUrlHostName('https://www.example.com/path')).toBe('example.com');
+ expect(getUrlHostName('https://www.example.com?query=123')).toBe(
+ 'example.com',
+ );
+ expect(getUrlHostName('http://localhost:3000')).toBe('localhost');
+ expect(getUrlHostName('example.com')).toBe('example.com');
+ expect(getUrlHostName('www.subdomain.example.com')).toBe(
+ 'subdomain.example.com',
+ );
+ });
+
+ it('returns an empty string for invalid URLs', () => {
+ expect(getUrlHostName('?o')).toBe('');
+ expect(getUrlHostName('')).toBe('');
+ expect(getUrlHostName('\\')).toBe('');
+ });
+});
diff --git a/packages/twenty-front/src/utils/url/getUrlHostName.ts b/packages/twenty-front/src/utils/url/getUrlHostName.ts
new file mode 100644
index 000000000..41e8b8096
--- /dev/null
+++ b/packages/twenty-front/src/utils/url/getUrlHostName.ts
@@ -0,0 +1,10 @@
+import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
+
+export const getUrlHostName = (url: string) => {
+ try {
+ const absoluteUrl = absoluteUrlSchema.parse(url);
+ return new URL(absoluteUrl).hostname.replace(/^www\./i, '');
+ } catch {
+ return '';
+ }
+};
diff --git a/packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts b/packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts
new file mode 100644
index 000000000..d20f3bcf6
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation-schemas/__tests__/absoluteUrlSchema.test.ts
@@ -0,0 +1,34 @@
+import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
+
+describe('absoluteUrlSchema', () => {
+ it('validates an absolute url', () => {
+ expect(absoluteUrlSchema.parse('https://www.example.com')).toBe(
+ 'https://www.example.com',
+ );
+ expect(absoluteUrlSchema.parse('http://subdomain.example.com')).toBe(
+ 'http://subdomain.example.com',
+ );
+ expect(absoluteUrlSchema.parse('https://www.example.com/path')).toBe(
+ 'https://www.example.com/path',
+ );
+ expect(absoluteUrlSchema.parse('https://www.example.com?query=123')).toBe(
+ 'https://www.example.com?query=123',
+ );
+ expect(absoluteUrlSchema.parse('http://localhost:3000')).toBe(
+ 'http://localhost:3000',
+ );
+ });
+
+ it('transforms a non-absolute URL to an absolute URL', () => {
+ expect(absoluteUrlSchema.parse('example.com')).toBe('https://example.com');
+ expect(absoluteUrlSchema.parse('www.subdomain.example.com')).toBe(
+ 'https://www.subdomain.example.com',
+ );
+ });
+
+ it('fails for invalid urls', () => {
+ expect(absoluteUrlSchema.safeParse('?o').success).toBe(false);
+ expect(absoluteUrlSchema.safeParse('').success).toBe(false);
+ expect(absoluteUrlSchema.safeParse('\\').success).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts b/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts
new file mode 100644
index 000000000..e0c376c74
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation-schemas/absoluteUrlSchema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod';
+
+export const absoluteUrlSchema = z
+ .string()
+ .url()
+ .or(
+ z
+ .string()
+ .transform((value) => `https://${value}`)
+ .pipe(z.string().url()),
+ );
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory.ts
index c12124007..0a89d8b61 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-input-type-definition.factory.ts
@@ -69,7 +69,9 @@ export class CompositeInputTypeDefinitionFactory {
options,
{
nullable: !property.isRequired,
- isArray: property.type === FieldMetadataType.MULTI_SELECT,
+ isArray:
+ property.type === FieldMetadataType.MULTI_SELECT ||
+ property.isArray,
},
);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory.ts
index e39da0ae3..f7f6cecc8 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/composite-object-type-definition.factory.ts
@@ -69,7 +69,9 @@ export class CompositeObjectTypeDefinitionFactory {
options,
{
nullable: !property.isRequired,
- isArray: property.type === FieldMetadataType.MULTI_SELECT,
+ isArray:
+ property.type === FieldMetadataType.MULTI_SELECT ||
+ property.isArray,
},
);
diff --git a/packages/twenty-server/src/engine/api/rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts
index eec9a40c3..c7112e0df 100644
--- a/packages/twenty-server/src/engine/api/rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts
+++ b/packages/twenty-server/src/engine/api/rest/api-rest-query-builder/utils/map-field-metadata-to-graphql-query.utils.ts
@@ -92,6 +92,15 @@ export const mapFieldMetadataToGraphqlQuery = (
url
}
`;
+ } else if (fieldType === FieldMetadataType.LINKS) {
+ return `
+ ${field.name}
+ {
+ primaryLinkLabel
+ primaryLinkUrl
+ secondaryLinks
+ }
+ `;
} else if (fieldType === FieldMetadataType.CURRENCY) {
return `
${field.name}
diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
index 8cba00761..83bf41b9e 100644
--- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
+++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts
@@ -57,6 +57,7 @@ const getSchemaComponentsProperties = (
}
break;
case FieldMetadataType.LINK:
+ case FieldMetadataType.LINKS:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.ADDRESS:
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts
index 35b3c740b..913748d76 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts
@@ -15,9 +15,10 @@ import {
} from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
- AddressMetadata,
addressCompositeType,
+ AddressMetadata,
} from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
+import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
export type CompositeFieldsDefinitionFunction = (
fieldMetadata?: FieldMetadataInterface,
@@ -28,6 +29,7 @@ export const compositeTypeDefintions = new Map<
CompositeType
>([
[FieldMetadataType.LINK, linkCompositeType],
+ [FieldMetadataType.LINKS, linksCompositeType],
[FieldMetadataType.CURRENCY, currencyCompositeType],
[FieldMetadataType.FULL_NAME, fullNameCompositeType],
[FieldMetadataType.ADDRESS, addressCompositeType],
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts
new file mode 100644
index 000000000..e04affd16
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/links.composite-type.ts
@@ -0,0 +1,33 @@
+import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
+
+import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+
+export const linksCompositeType: CompositeType = {
+ type: FieldMetadataType.LINKS,
+ properties: [
+ {
+ name: 'primaryLinkLabel',
+ type: FieldMetadataType.TEXT,
+ hidden: false,
+ isRequired: false,
+ },
+ {
+ name: 'primaryLinkUrl',
+ type: FieldMetadataType.TEXT,
+ hidden: false,
+ isRequired: false,
+ },
+ {
+ name: 'secondaryLinks',
+ type: FieldMetadataType.RAW_JSON,
+ hidden: false,
+ isRequired: false,
+ },
+ ],
+};
+
+export type LinksMetadata = {
+ primaryLinkLabel: string;
+ primaryLinkUrl: string;
+ secondaryLinks: JSON | null;
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts
index 421227303..1fcc000f3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts
@@ -138,3 +138,17 @@ export class FieldMetadataDefaultValueAddress {
@IsNumber()
addressLng: number | null;
}
+
+export class FieldMetadataDefaultValueLinks {
+ @ValidateIf((_object, value) => value !== null)
+ @IsQuotedString()
+ primaryLinkLabel: string | null;
+
+ @ValidateIf((_object, value) => value !== null)
+ @IsQuotedString()
+ primaryLinkUrl: string | null;
+
+ @ValidateIf((_object, value) => value !== null)
+ @IsJSON()
+ secondaryLinks: JSON | null;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts
index 96d504016..dd24719ad 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts
@@ -31,6 +31,7 @@ export enum FieldMetadataType {
NUMERIC = 'NUMERIC',
PROBABILITY = 'PROBABILITY',
LINK = 'LINK',
+ LINKS = 'LINKS',
CURRENCY = 'CURRENCY',
FULL_NAME = 'FULL_NAME',
RATING = 'RATING',
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface.ts
index 1c812957e..156ce2f09 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface.ts
@@ -6,6 +6,7 @@ export interface CompositeProperty {
type: FieldMetadataType;
hidden: 'input' | 'output' | true | false;
isRequired: boolean;
+ isArray?: boolean;
}
export interface CompositeType {
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts
index 5ca1ce6d4..c78af0480 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts
@@ -10,6 +10,7 @@ import {
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueUuidFunction,
FieldMetadataDefaultValueNowFunction,
+ FieldMetadataDefaultValueLinks,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -36,6 +37,7 @@ type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.NUMERIC]: FieldMetadataDefaultValueString;
[FieldMetadataType.PROBABILITY]: FieldMetadataDefaultValueNumber;
[FieldMetadataType.LINK]: FieldMetadataDefaultValueLink;
+ [FieldMetadataType.LINKS]: FieldMetadataDefaultValueLinks;
[FieldMetadataType.CURRENCY]: FieldMetadataDefaultValueCurrency;
[FieldMetadataType.FULL_NAME]: FieldMetadataDefaultValueFullName;
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress;
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts
index 2ec5a820e..b6e0b1b4b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts
@@ -36,6 +36,12 @@ export function generateDefaultValue(
amountMicros: null,
currencyCode: "''",
};
+ case FieldMetadataType.LINKS:
+ return {
+ primaryLinkLabel: "''",
+ primaryLinkUrl: "''",
+ secondaryLinks: null,
+ };
default:
return null;
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts
index 81a7cdb6c..d529e54f6 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts
@@ -5,11 +5,14 @@ export const isCompositeFieldMetadataType = (
): type is
| FieldMetadataType.LINK
| FieldMetadataType.CURRENCY
- | FieldMetadataType.FULL_NAME => {
- return (
- type === FieldMetadataType.LINK ||
- type === FieldMetadataType.CURRENCY ||
- type === FieldMetadataType.FULL_NAME ||
- type === FieldMetadataType.ADDRESS
- );
+ | FieldMetadataType.FULL_NAME
+ | FieldMetadataType.ADDRESS
+ | FieldMetadataType.LINKS => {
+ return [
+ FieldMetadataType.LINK,
+ FieldMetadataType.CURRENCY,
+ FieldMetadataType.FULL_NAME,
+ FieldMetadataType.ADDRESS,
+ FieldMetadataType.LINKS,
+ ].includes(type);
};
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts
index ab22f3c00..bb7ec90f8 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts
@@ -21,6 +21,7 @@ import {
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueUuidFunction,
FieldMetadataDefaultValueDate,
+ FieldMetadataDefaultValueLinks,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
@@ -49,6 +50,7 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
[FieldMetadataType.ADDRESS]: [FieldMetadataDefaultValueAddress],
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
+ [FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
};
type ValidationResult = {
diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts
index f449b1400..5947ba2c9 100644
--- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts
@@ -18,7 +18,8 @@ export type CompositeFieldMetadataType =
| FieldMetadataType.ADDRESS
| FieldMetadataType.CURRENCY
| FieldMetadataType.FULL_NAME
- | FieldMetadataType.LINK;
+ | FieldMetadataType.LINK
+ | FieldMetadataType.LINKS;
@Injectable()
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory {
@@ -51,6 +52,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory