mirror of
https://github.com/twentyhq/twenty.git
synced 2024-10-04 04:47:38 +03:00
feat: add Links field type (#5176)
Closes #5113 --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
parent
e0ece3c917
commit
8853226d17
@ -305,6 +305,7 @@ export enum FieldMetadataType {
|
||||
Email = 'EMAIL',
|
||||
FullName = 'FULL_NAME',
|
||||
Link = 'LINK',
|
||||
Links = 'LINKS',
|
||||
MultiSelect = 'MULTI_SELECT',
|
||||
Number = 'NUMBER',
|
||||
Numeric = 'NUMERIC',
|
||||
|
@ -212,6 +212,7 @@ export enum FieldMetadataType {
|
||||
Email = 'EMAIL',
|
||||
FullName = 'FULL_NAME',
|
||||
Link = 'LINK',
|
||||
Links = 'LINKS',
|
||||
MultiSelect = 'MULTI_SELECT',
|
||||
Number = 'NUMBER',
|
||||
Numeric = 'NUMERIC',
|
||||
|
@ -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 = () => {
|
||||
<NumberFieldDisplay />
|
||||
) : isFieldLink(fieldDefinition) ? (
|
||||
<LinkFieldDisplay />
|
||||
) : isFieldLinks(fieldDefinition) ? (
|
||||
<LinksFieldDisplay />
|
||||
) : isFieldCurrency(fieldDefinition) ? (
|
||||
<CurrencyFieldDisplay />
|
||||
) : isFieldFullName(fieldDefinition) ? (
|
||||
|
@ -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) ? (
|
||||
<LinksFieldInput
|
||||
onEnter={onEnter}
|
||||
onEscape={onEscape}
|
||||
onClickOutside={onClickOutside}
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : isFieldCurrency(fieldDefinition) ? (
|
||||
<CurrencyFieldInput
|
||||
onEnter={onEnter}
|
||||
|
@ -7,6 +7,8 @@ import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDa
|
||||
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
@ -69,6 +71,9 @@ export const usePersistField = () => {
|
||||
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 }),
|
||||
|
@ -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 <LinksDisplay value={fieldValue} />;
|
||||
};
|
@ -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<FieldLinksValue>(
|
||||
recordStoreFamilySelector({
|
||||
recordId: entityId,
|
||||
fieldName: fieldName,
|
||||
}),
|
||||
);
|
||||
|
||||
const { setDraftValue, getDraftValueSelector } =
|
||||
useRecordFieldInput<FieldLinksValue>(`${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,
|
||||
};
|
||||
};
|
@ -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 (
|
||||
<FieldInputOverlay>
|
||||
<TextInput
|
||||
value={draftValue?.primaryLinkUrl ?? ''}
|
||||
autoFocus
|
||||
placeholder="Links"
|
||||
hotkeyScope={hotkeyScope}
|
||||
onClickOutside={handleClickOutside}
|
||||
onEnter={handleEnter}
|
||||
onEscape={handleEscape}
|
||||
onTab={handleTab}
|
||||
onShiftTab={handleShiftTab}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</FieldInputOverlay>
|
||||
);
|
||||
};
|
@ -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> = 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;
|
||||
|
@ -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;
|
||||
|
@ -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: (
|
||||
|
@ -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;
|
||||
|
@ -0,0 +1,9 @@
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
import { FieldDefinition } from '../FieldDefinition';
|
||||
import { FieldLinksMetadata, FieldMetadata } from '../FieldMetadata';
|
||||
|
||||
export const isFieldLinks = (
|
||||
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
|
||||
): field is FieldDefinition<FieldLinksMetadata> =>
|
||||
field.type === FieldMetadataType.Links;
|
@ -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<FieldLinksValue>;
|
||||
|
||||
export const isFieldLinksValue = (
|
||||
fieldValue: unknown,
|
||||
): fieldValue is FieldLinksValue => linksSchema.safeParse(fieldValue).success;
|
@ -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}}`,
|
||||
);
|
||||
|
@ -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: '',
|
||||
|
@ -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, '')
|
||||
: '';
|
||||
};
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -67,6 +67,7 @@ const previewableTypes = [
|
||||
FieldMetadataType.Select,
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.Links,
|
||||
FieldMetadataType.Number,
|
||||
FieldMetadataType.Rating,
|
||||
FieldMetadataType.Relation,
|
||||
|
@ -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 <LinkDisplay value={{ url, label }} />;
|
||||
};
|
@ -282,6 +282,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
FieldMetadataType.Email,
|
||||
FieldMetadataType.FullName,
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.Links,
|
||||
FieldMetadataType.Numeric,
|
||||
FieldMetadataType.Phone,
|
||||
FieldMetadataType.Probability,
|
||||
|
@ -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('');
|
||||
});
|
||||
});
|
10
packages/twenty-front/src/utils/url/getUrlHostName.ts
Normal file
10
packages/twenty-front/src/utils/url/getUrlHostName.ts
Normal file
@ -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 '';
|
||||
}
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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()),
|
||||
);
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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}
|
||||
|
@ -57,6 +57,7 @@ const getSchemaComponentsProperties = (
|
||||
}
|
||||
break;
|
||||
case FieldMetadataType.LINK:
|
||||
case FieldMetadataType.LINKS:
|
||||
case FieldMetadataType.CURRENCY:
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
case FieldMetadataType.ADDRESS:
|
||||
|
@ -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],
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ export enum FieldMetadataType {
|
||||
NUMERIC = 'NUMERIC',
|
||||
PROBABILITY = 'PROBABILITY',
|
||||
LINK = 'LINK',
|
||||
LINKS = 'LINKS',
|
||||
CURRENCY = 'CURRENCY',
|
||||
FULL_NAME = 'FULL_NAME',
|
||||
RATING = 'RATING',
|
||||
|
@ -6,6 +6,7 @@ export interface CompositeProperty {
|
||||
type: FieldMetadataType;
|
||||
hidden: 'input' | 'output' | true | false;
|
||||
isRequired: boolean;
|
||||
isArray?: boolean;
|
||||
}
|
||||
|
||||
export interface CompositeType {
|
||||
|
@ -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;
|
||||
|
@ -36,6 +36,12 @@ export function generateDefaultValue(
|
||||
amountMicros: null,
|
||||
currencyCode: "''",
|
||||
};
|
||||
case FieldMetadataType.LINKS:
|
||||
return {
|
||||
primaryLinkLabel: "''",
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: null,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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<CompositeFieldMetadataType> {
|
||||
@ -51,6 +52,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
|
||||
columnType: fieldMetadataTypeToColumnType(property.type),
|
||||
isNullable: fieldMetadata.isNullable || !property.isRequired,
|
||||
defaultValue: serializedDefaultValue,
|
||||
isArray: property.isArray,
|
||||
});
|
||||
}
|
||||
|
||||
@ -116,6 +118,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
|
||||
defaultValue: serializeDefaultValue(
|
||||
currentFieldMetadata.defaultValue?.[currentProperty.name],
|
||||
),
|
||||
isArray: currentProperty.isArray,
|
||||
},
|
||||
alteredColumnDefinition: {
|
||||
columnName: alteredColumnName,
|
||||
@ -123,6 +126,7 @@ export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<Co
|
||||
isNullable:
|
||||
alteredFieldMetadata.isNullable || !alteredProperty.isRequired,
|
||||
defaultValue: serializedDefaultValue,
|
||||
isArray: alteredProperty.isArray,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -94,6 +94,7 @@ export class WorkspaceMigrationFactory {
|
||||
FieldMetadataType.FULL_NAME,
|
||||
{ factory: this.compositeColumnActionFactory },
|
||||
],
|
||||
[FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }],
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
ObjectLiteral,
|
||||
} from 'typeorm';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import { DeepPartial } from 'typeorm/common/DeepPartial';
|
||||
|
||||
import { PartialFieldMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-field-metadata.interface';
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user