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