From 0527bc296ee3ba61017bb07d6effbbca3c314b2a Mon Sep 17 00:00:00 2001 From: Guillim Date: Mon, 2 Dec 2024 13:34:05 +0100 Subject: [PATCH] =?UTF-8?q?Default=20address=20country=20=F0=9F=97=BA?= =?UTF-8?q?=EF=B8=8F=20&=20Phone=20prefix=20=E2=98=8E=EF=B8=8F=20=20(#8614?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Default address 🗺️ country & Phone ☎️ country We add the ability to add a Default address country and a default Phone country for fields in the Data model. fix #8081 --------- Co-authored-by: Charles Bochet --- .../constants/FieldsNotOverwrittenAtDraft.ts | 1 + .../meta-types/hooks/useNumberField.ts | 4 +- .../input/components/MultiItemFieldInput.tsx | 2 +- .../input/components/PhonesFieldInput.tsx | 57 +++++++----- .../input/components/PhonesFieldMenuItem.tsx | 4 +- .../record-field/types/FieldMetadata.ts | 44 +++++----- .../types/guards/isFieldAddressValue.ts | 2 +- .../types/guards/isFieldPhonesValue.ts | 2 +- .../utils/computeDraftValueFromFieldValue.ts | 35 ++++++++ .../SettingsOptionCardContentSelect.tsx | 37 ++++---- .../SettingsCompositeFieldTypeConfigs.ts | 4 +- .../SettingsDataModelFieldAddressForm.tsx | 86 +++++++++++++++++++ ...sDataModelFieldAddressSettingsFormCard.tsx | 45 ++++++++++ ...SettingsDataModelFieldSettingsFormCard.tsx | 34 ++++++++ .../SettingsDataModelFieldPhonesForm.tsx | 80 +++++++++++++++++ ...gsDataModelFieldPhonesSettingsFormCard.tsx | 46 ++++++++++ .../preview/hooks/useFieldPreviewValue.ts | 6 ++ .../utils/getAddressFieldPreviewValue.ts | 34 ++++++++ .../utils/getPhonesFieldPreviewValue.ts | 33 +++++++ .../field/display/components/PhoneDisplay.tsx | 33 ++++--- .../display/components/PhonesDisplay.tsx | 14 +-- .../modules/ui/input/components/Select.tsx | 6 +- .../country/components/CountrySelect.tsx | 22 +++-- .../components/internal/types/Country.ts | 4 +- .../string/stripSimpleQuotesFromString.ts | 17 ++++ .../field-metadata-validation.service.ts | 12 ++- .../field-metadata-validation.service.spec.ts | 59 +++++++++++++ .../src/utils/computeInputFields.ts | 2 +- 28 files changed, 617 insertions(+), 108 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts diff --git a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts index 902b2f878..86397c9af 100644 --- a/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts +++ b/packages/twenty-front/src/modules/object-record/constants/FieldsNotOverwrittenAtDraft.ts @@ -2,5 +2,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [ FieldMetadataType.Address, + FieldMetadataType.Phones, FieldMetadataType.Links, ]; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts index 4b27ad3ae..996b77a14 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts @@ -35,8 +35,8 @@ export const useNumberField = () => { const persistNumberField = (newValue: string) => { if (fieldDefinition?.metadata?.settings?.type === 'percentage') { - newValue = newValue.replaceAll('%', ''); - if (!canBeCastAsNumberOrNull(newValue)) { + const newValueEscaped = newValue.replaceAll('%', ''); + if (!canBeCastAsNumberOrNull(newValueEscaped)) { return; } const castedValue = castAsNumberOrNull(newValue); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx index 53ffe87e2..5ba3bebad 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx @@ -111,7 +111,7 @@ export const MultiItemFieldInput = ({ break; case FieldMetadataType.Phones: item = items[index] as PhoneRecord; - setInputValue(item.countryCode + item.number); + setInputValue(`+${item.callingCode}` + item.number); break; case FieldMetadataType.Emails: item = items[index] as string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx index 97154d93e..90308beef 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx @@ -5,12 +5,14 @@ import { E164Number, parsePhoneNumber } from 'libphonenumber-js'; import { useMemo } from 'react'; import ReactPhoneNumberInput from 'react-phone-number-input'; import 'react-phone-number-input/style.css'; -import { isDefined, TEXT_INPUT_STYLE } from 'twenty-ui'; +import { TEXT_INPUT_STYLE, isDefined } from 'twenty-ui'; import { MultiItemFieldInput } from './MultiItemFieldInput'; +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)` font-family: ${({ theme }) => theme.font.family}; @@ -48,33 +50,41 @@ type PhonesFieldInputProps = { }; export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => { - const { persistPhonesField, hotkeyScope, fieldValue } = usePhonesField(); + const { persistPhonesField, hotkeyScope, draftValue, fieldDefinition } = + usePhonesField(); - const phones = useMemo<{ number: string; countryCode: string }[]>( - () => - [ - fieldValue.primaryPhoneNumber - ? { - number: fieldValue.primaryPhoneNumber, - countryCode: fieldValue.primaryPhoneCountryCode, - } - : null, - ...(fieldValue.additionalPhones ?? []), - ].filter(isDefined), - [ - fieldValue.primaryPhoneNumber, - fieldValue.primaryPhoneCountryCode, - fieldValue.additionalPhones, - ], - ); + const phones = useMemo<{ number: string; callingCode: string }[]>(() => { + if (!isDefined(draftValue)) { + return []; + } + return [ + draftValue.primaryPhoneNumber + ? { + number: draftValue.primaryPhoneNumber, + callingCode: draftValue.primaryPhoneCountryCode, + } + : null, + ...(draftValue.additionalPhones ?? []), + ].filter(isDefined); + }, [draftValue]); + + const defaultCallingCode = + stripSimpleQuotesFromString( + fieldDefinition?.defaultValue?.primaryPhoneCountryCode, + ) ?? '+1'; + + // TODO : improve once we store the real country code + const defaultCountry = useCountries().find( + (obj) => obj.callingCode === defaultCallingCode, + )?.countryCode; const handlePersistPhones = ( - updatedPhones: { number: string; countryCode: string }[], + updatedPhones: { number: string; callingCode: string }[], ) => { const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones; persistPhonesField({ primaryPhoneNumber: nextPrimaryPhone?.number ?? '', - primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '', + primaryPhoneCountryCode: nextPrimaryPhone?.callingCode ?? '', additionalPhones: nextAdditionalPhones, }); }; @@ -93,12 +103,12 @@ export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => { if (phone !== undefined) { return { number: phone.nationalNumber, - countryCode: `+${phone.countryCallingCode}`, + callingCode: `${phone.countryCallingCode}`, }; } return { number: '', - countryCode: '', + callingCode: '', }; }} renderItem={({ @@ -128,6 +138,7 @@ export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => { international={true} withCountryCallingCode={true} countrySelectComponent={PhoneCountryPickerDropdownButton} + defaultCountry={defaultCountry} /> ); }} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx index 3f793d38f..d89b5eec4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx @@ -7,7 +7,7 @@ type PhonesFieldMenuItemProps = { onEdit?: () => void; onSetAsPrimary?: () => void; onDelete?: () => void; - phone: { number: string; countryCode: string }; + phone: { number: string; callingCode: string }; }; export const PhonesFieldMenuItem = ({ @@ -22,7 +22,7 @@ export const PhonesFieldMenuItem = ({ ; + settings?: null; }; export type FieldBooleanMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldTextMetadata = { @@ -61,13 +61,13 @@ export type FieldLinkMetadata = { objectMetadataNameSingular?: string; placeHolder: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldLinksMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldCurrencyMetadata = { @@ -75,66 +75,66 @@ export type FieldCurrencyMetadata = { fieldName: string; placeHolder: string; isPositive?: boolean; - settings?: Record; + settings?: null; }; export type FieldFullNameMetadata = { objectMetadataNameSingular?: string; placeHolder: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldEmailMetadata = { objectMetadataNameSingular?: string; placeHolder: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldEmailsMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldPhoneMetadata = { objectMetadataNameSingular?: string; placeHolder: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldRatingMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldAddressMetadata = { objectMetadataNameSingular?: string; placeHolder: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldRawJsonMetadata = { objectMetadataNameSingular?: string; fieldName: string; placeHolder: string; - settings?: Record; + settings?: null; }; export type FieldRichTextMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldPositionMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldRelationMetadata = { @@ -146,7 +146,7 @@ export type FieldRelationMetadata = { relationType?: RelationDefinitionType; targetFieldMetadataName?: string; useEditButton?: boolean; - settings?: Record; + settings?: null; }; export type FieldSelectMetadata = { @@ -154,39 +154,39 @@ export type FieldSelectMetadata = { fieldName: string; options: { label: string; color: ThemeColor; value: string }[]; isNullable: boolean; - settings?: Record; + settings?: null; }; export type FieldMultiSelectMetadata = { objectMetadataNameSingular?: string; fieldName: string; options: { label: string; color: ThemeColor; value: string }[]; - settings?: Record; + settings?: null; }; export type FieldActorMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldArrayMetadata = { objectMetadataNameSingular?: string; fieldName: string; values: { label: string; value: string }[]; - settings?: Record; + settings?: null; }; export type FieldPhonesMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldTsVectorMetadata = { objectMetadataNameSingular?: string; fieldName: string; - settings?: Record; + settings?: null; }; export type FieldMetadata = @@ -265,7 +265,7 @@ export type FieldActorValue = { export type FieldArrayValue = string[]; -export type PhoneRecord = { number: string; countryCode: string }; +export type PhoneRecord = { number: string; callingCode: string }; export type FieldPhonesValue = { primaryPhoneNumber: string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts index 8bc33766e..1524f4bd2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { FieldAddressValue } from '../FieldMetadata'; -const addressSchema = z.object({ +export const addressSchema = z.object({ addressStreet1: z.string(), addressStreet2: z.string().nullable(), addressCity: z.string().nullable(), diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts index fa60d1980..90cb812da 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts @@ -6,7 +6,7 @@ export const phonesSchema = z.object({ primaryPhoneNumber: z.string(), primaryPhoneCountryCode: z.string(), additionalPhones: z - .array(z.object({ number: z.string(), countryCode: z.string() })) + .array(z.object({ number: z.string(), callingCode: z.string() })) .nullable(), }) satisfies z.ZodType; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index 47a404873..a22e42259 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -1,10 +1,12 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumberValue } from '@/object-record/record-field/types/guards/isFieldNumberValue'; +import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; @@ -12,6 +14,7 @@ import { computeEmptyDraftValue } from '@/object-record/record-field/utils/compu import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; type computeDraftValueFromFieldValueParams = { fieldDefinition: Pick, 'type' | 'metadata'>; @@ -42,6 +45,38 @@ export const computeDraftValueFromFieldValue = ({ } as unknown as FieldInputDraftValue; } + if (isFieldAddress(fieldDefinition)) { + if ( + isFieldValueEmpty({ fieldValue, fieldDefinition }) && + !!fieldDefinition?.defaultValue?.addressCountry + ) { + return { + ...fieldValue, + addressCountry: stripSimpleQuotesFromString( + fieldDefinition?.defaultValue?.addressCountry, + ), + } as unknown as FieldInputDraftValue; + } + + return fieldValue as FieldInputDraftValue; + } + + if (isFieldPhones(fieldDefinition)) { + if ( + isFieldValueEmpty({ fieldValue, fieldDefinition }) && + !!fieldDefinition?.defaultValue?.primaryPhoneCountryCode + ) { + return { + ...fieldValue, + primaryPhoneCountryCode: stripSimpleQuotesFromString( + fieldDefinition?.defaultValue?.primaryPhoneCountryCode, + ), + } as unknown as FieldInputDraftValue; + } + + return fieldValue as FieldInputDraftValue; + } + if ( isFieldNumber(fieldDefinition) && isFieldNumberValue(fieldValue) && diff --git a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentSelect.tsx b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentSelect.tsx index f84c54ec4..333b6b09e 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentSelect.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsOptions/SettingsOptionCardContentSelect.tsx @@ -5,17 +5,10 @@ import { StyledSettingsOptionCardTitle, } from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase'; import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer'; -import { Select } from '@/ui/input/components/Select'; +import { Select, SelectValue } from '@/ui/input/components/Select'; import styled from '@emotion/styled'; import { IconComponent } from 'twenty-ui'; -const StyledSettingsOptionCardSelect = styled(Select)` - margin-left: auto; - width: 120px; -`; - -type SelectValue = string | number | boolean | null; - type SettingsOptionCardContentSelectProps = { Icon?: IconComponent; title: React.ReactNode; @@ -23,7 +16,7 @@ type SettingsOptionCardContentSelectProps = { divider?: boolean; disabled?: boolean; value: Value; - onChange: (value: SelectValue) => void; + onChange: (value: Value) => void; options: { value: Value; label: string; @@ -34,6 +27,10 @@ type SettingsOptionCardContentSelectProps = { fullWidth?: boolean; }; +const StyledSelectContainer = styled.div` + margin-left: auto; +`; + export const SettingsOptionCardContentSelect = ({ Icon, title, @@ -60,16 +57,18 @@ export const SettingsOptionCardContentSelect = ({ {description} - + + + className={selectClassName} + dropdownWidth={fullWidth ? 'auto' : 120} + disabled={disabled} + dropdownId={dropdownId} + value={value} + onChange={onChange} + options={options} + selectSizeVariant="small" + /> + ); }; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index b2fb02c29..d139f634e 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -91,7 +91,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { exampleValue: { primaryPhoneNumber: '234-567-890', primaryPhoneCountryCode: '+1', - additionalPhones: [{ number: '234-567-890', countryCode: '+1' }], + additionalPhones: [{ number: '234-567-890', callingCode: '+1' }], }, subFields: [ 'primaryPhoneNumber', @@ -151,7 +151,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { }, exampleValue: { addressStreet1: '456 Oak Street', - addressStreet2: 'Unit 3B', + addressStreet2: '', addressCity: 'Springfield', addressState: 'California', addressCountry: 'United States', diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm.tsx new file mode 100644 index 000000000..c382a0aed --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm.tsx @@ -0,0 +1,86 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { addressSchema as addressFieldDefaultValueSchema } from '@/object-record/record-field/types/guards/isFieldAddressValue'; +import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; +import { IconMap } from 'twenty-ui'; +import { z } from 'zod'; +import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; + +type SettingsDataModelFieldAddressFormProps = { + disabled?: boolean; + defaultCountry?: string; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'defaultValue' | 'settings' + >; +}; + +export const settingsDataModelFieldAddressFormSchema = z.object({ + defaultValue: addressFieldDefaultValueSchema, +}); + +export type SettingsDataModelFieldTextFormValues = z.infer< + typeof settingsDataModelFieldAddressFormSchema +>; + +export const SettingsDataModelFieldAddressForm = ({ + disabled, + fieldMetadataItem, +}: SettingsDataModelFieldAddressFormProps) => { + const { control } = useFormContext(); + const countries = useCountries() + .sort((a, b) => a.countryName.localeCompare(b.countryName)) + .map((country) => ({ + label: country.countryName, + value: country.countryName, + })); + countries.unshift({ + label: 'No country', + value: '', + }); + const defaultDefaultValue = { + addressStreet1: "''", + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, + addressCountry: null, + addressLat: null, + addressLng: null, + }; + + return ( + { + const defaultCountry = value?.addressCountry || ''; + return ( + + Icon={IconMap} + dropdownId="selectDefaultCountry" + title="Default Country" + description="The default country for new addresses" + value={stripSimpleQuotesFromString(defaultCountry)} + onChange={(newCountry) => + onChange({ + ...value, + addressCountry: applySimpleQuotesToString(newCountry), + }) + } + disabled={disabled} + options={countries} + fullWidth={true} + /> + ); + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard.tsx new file mode 100644 index 000000000..ed7aba3ee --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard.tsx @@ -0,0 +1,45 @@ +import styled from '@emotion/styled'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; + +import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; +import { SettingsDataModelFieldAddressForm } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm'; +import { + SettingsDataModelFieldPreviewCard, + SettingsDataModelFieldPreviewCardProps, +} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; + +type SettingsDataModelFieldAddressSettingsFormCardProps = { + disabled?: boolean; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'defaultValue' + >; +} & Pick; + +const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` + flex: 1 1 100%; +`; + +export const SettingsDataModelFieldAddressSettingsFormCard = ({ + disabled, + fieldMetadataItem, + objectMetadataItem, +}: SettingsDataModelFieldAddressSettingsFormCardProps) => { + return ( + + } + form={ + + } + /> + ); +}; 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 31f16b2ff..f0d7f4bdc 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 @@ -5,6 +5,8 @@ import { z } from 'zod'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs'; +import { settingsDataModelFieldAddressFormSchema } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm'; +import { SettingsDataModelFieldAddressSettingsFormCard } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard'; import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm'; import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard'; import { settingsDataModelFieldtextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm'; @@ -15,6 +17,8 @@ import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fiel import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard'; import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm'; import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard'; +import { settingsDataModelFieldPhonesFormSchema } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm'; +import { SettingsDataModelFieldPhonesSettingsFormCard } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard'; import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm'; import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard'; import { @@ -64,6 +68,14 @@ const textFieldFormSchema = z .object({ type: z.literal(FieldMetadataType.Text) }) .merge(settingsDataModelFieldtextFormSchema); +const addressFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Address) }) + .merge(settingsDataModelFieldAddressFormSchema); + +const phonesFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Phones) }) + .merge(settingsDataModelFieldPhonesFormSchema); + const otherFieldsFormSchema = z.object({ type: z.enum( Object.keys( @@ -76,6 +88,8 @@ const otherFieldsFormSchema = z.object({ FieldMetadataType.Date, FieldMetadataType.DateTime, FieldMetadataType.Number, + FieldMetadataType.Address, + FieldMetadataType.Phones, FieldMetadataType.Text, ]), ) as [FieldMetadataType, ...FieldMetadataType[]], @@ -94,6 +108,8 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( multiSelectFieldFormSchema, numberFieldFormSchema, textFieldFormSchema, + addressFieldFormSchema, + phonesFieldFormSchema, otherFieldsFormSchema, ], ); @@ -195,6 +211,24 @@ export const SettingsDataModelFieldSettingsFormCard = ({ ); } + if (fieldMetadataItem.type === FieldMetadataType.Address) { + return ( + + ); + } + + if (fieldMetadataItem.type === FieldMetadataType.Phones) { + return ( + + ); + } + if ( fieldMetadataItem.type === FieldMetadataType.Select || fieldMetadataItem.type === FieldMetadataType.MultiSelect diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx new file mode 100644 index 000000000..d7217a4fb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx @@ -0,0 +1,80 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue'; +import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; +import { IconMap } from 'twenty-ui'; +import { z } from 'zod'; +import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; + +type SettingsDataModelFieldPhonesFormProps = { + disabled?: boolean; + defaultCountryCode?: string; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'defaultValue' | 'settings' + >; +}; + +export const settingsDataModelFieldPhonesFormSchema = z.object({ + defaultValue: phonesFieldDefaultValueSchema, +}); + +export type SettingsDataModelFieldTextFormValues = z.infer< + typeof settingsDataModelFieldPhonesFormSchema +>; + +export const SettingsDataModelFieldPhonesForm = ({ + disabled, + fieldMetadataItem, +}: SettingsDataModelFieldPhonesFormProps) => { + const { control } = useFormContext(); + + const countries = useCountries() + .sort((a, b) => a.countryName.localeCompare(b.countryName)) + .map((country) => ({ + label: `${country.countryName} (+${country.callingCode})`, + value: `${country.callingCode}`, + })); + countries.unshift({ label: 'No country', value: '' }); + const defaultDefaultValue = { + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "''", + additionalPhones: null, + }; + const fieldMetadataItemDefaultValue = fieldMetadataItem?.defaultValue; + + return ( + { + return ( + + Icon={IconMap} + dropdownId="selectDefaultCountryCode" + title="Default Country Code" + description="The default country code for new phone numbers." + value={stripSimpleQuotesFromString(value?.primaryPhoneCountryCode)} + onChange={(newPhoneCountryCode) => + onChange({ + ...value, + primaryPhoneCountryCode: + applySimpleQuotesToString(newPhoneCountryCode), + }) + } + disabled={disabled} + options={countries} + fullWidth={true} + /> + ); + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx new file mode 100644 index 000000000..70210d1be --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx @@ -0,0 +1,46 @@ +import styled from '@emotion/styled'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; + +import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; + +import { SettingsDataModelFieldPhonesForm } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm'; +import { + SettingsDataModelFieldPreviewCard, + SettingsDataModelFieldPreviewCardProps, +} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; + +type SettingsDataModelFieldPhonesSettingsFormCardProps = { + disabled?: boolean; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'defaultValue' + >; +} & Pick; + +const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` + flex: 1 1 100%; +`; + +export const SettingsDataModelFieldPhonesSettingsFormCard = ({ + disabled, + fieldMetadataItem, + objectMetadataItem, +}: SettingsDataModelFieldPhonesSettingsFormCardProps) => { + return ( + + } + form={ + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts index 422a7075f..7339cb956 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/hooks/useFieldPreviewValue.ts @@ -2,9 +2,11 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRelationFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useRelationFieldPreviewValue'; +import { getAddressFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue'; import { getCurrencyFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue'; import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue'; import { getMultiSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getMultiSelectFieldPreviewValue'; +import { getPhonesFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue'; import { getSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getSelectFieldPreviewValue'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -45,6 +47,10 @@ export const useFieldPreviewValue = ({ return getSelectFieldPreviewValue({ fieldMetadataItem }); case FieldMetadataType.MultiSelect: return getMultiSelectFieldPreviewValue({ fieldMetadataItem }); + case FieldMetadataType.Address: + return getAddressFieldPreviewValue({ fieldMetadataItem }); + case FieldMetadataType.Phones: + return getPhonesFieldPreviewValue({ fieldMetadataItem }); default: return getFieldPreviewValue({ fieldMetadataItem }); } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts new file mode 100644 index 000000000..55a842fcd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts @@ -0,0 +1,34 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; + +export const getAddressFieldPreviewValue = ({ + fieldMetadataItem, +}: { + fieldMetadataItem: Pick< + FieldMetadataItem, + 'defaultValue' | 'options' | 'type' + >; +}): FieldAddressValue | null => { + if (fieldMetadataItem.type !== FieldMetadataType.Address) return null; + + const addressFieldTypeConfig = getSettingsFieldTypeConfig( + FieldMetadataType.Address, + ); + + const placeholderDefaultValue = addressFieldTypeConfig.exampleValue; + + const addressCountry = + fieldMetadataItem.defaultValue?.addressCountry && + fieldMetadataItem.defaultValue.addressCountry !== '' + ? stripSimpleQuotesFromString( + fieldMetadataItem.defaultValue?.addressCountry, + ) + : null; + return { + ...placeholderDefaultValue, + addressCountry: addressCountry, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts new file mode 100644 index 000000000..79c749733 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts @@ -0,0 +1,33 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; +import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; + +export const getPhonesFieldPreviewValue = ({ + fieldMetadataItem, +}: { + fieldMetadataItem: Pick< + FieldMetadataItem, + 'defaultValue' | 'options' | 'type' + >; +}): FieldPhonesValue | null => { + if (fieldMetadataItem.type !== FieldMetadataType.Phones) return null; + + const phonesFieldTypeConfig = getSettingsFieldTypeConfig( + FieldMetadataType.Phones, + ); + + const placeholderDefaultValue = phonesFieldTypeConfig.exampleValue; + const primaryPhoneCountryCode = + fieldMetadataItem.defaultValue?.primaryPhoneCountryCode && + fieldMetadataItem.defaultValue.primaryPhoneCountryCode !== '' + ? `+${stripSimpleQuotesFromString( + fieldMetadataItem.defaultValue?.primaryPhoneCountryCode, + )}` + : null; + return { + ...placeholderDefaultValue, + primaryPhoneCountryCode, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx index 02c518278..78accbd33 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx @@ -4,28 +4,37 @@ import { ContactLink } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; -type PhoneDisplayProps = { - value: string | null | undefined; +interface PhoneDisplayProps { + value: PhoneDisplayValueProps; +} +type PhoneDisplayValueProps = { + number: string | null | undefined; + callingCode: string | null | undefined; }; -// TODO: see if we can find a faster way to format the phone number -export const PhoneDisplay = ({ value }: PhoneDisplayProps) => { - if (!isDefined(value)) { - return {value}; - } +export const PhoneDisplay = ({ + value: { number, callingCode }, +}: PhoneDisplayProps) => { + if (!isDefined(number)) return {number}; + + const callingCodeSanitized = callingCode?.replace('+', ''); let parsedPhoneNumber: PhoneNumber | null = null; try { - // TODO: parse according to locale not hard coded FR - parsedPhoneNumber = parsePhoneNumber(value, 'FR'); + parsedPhoneNumber = parsePhoneNumber(number, { + defaultCallingCode: callingCodeSanitized || '1', + }); } catch (error) { - return {value}; + if (!(error instanceof Error)) + return {number}; + if (error.message === 'NOT_A_NUMBER') + return {`+${callingCodeSanitized}`}; + return {number}; } const URI = parsedPhoneNumber.getURI(); const formatedPhoneNumber = parsedPhoneNumber.formatInternational(); - return ( { event.stopPropagation(); }} > - {formatedPhoneNumber || value} + {formatedPhoneNumber || number} ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx index 6dbca8829..745d5960b 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx @@ -36,16 +36,16 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => { value?.primaryPhoneNumber ? { number: value.primaryPhoneNumber, - countryCode: value.primaryPhoneCountryCode, + callingCode: value.primaryPhoneCountryCode, } : null, ...parseAdditionalPhones(value?.additionalPhones), ] .filter(isDefined) - .map(({ number, countryCode }) => { + .map(({ number, callingCode }) => { return { number, - countryCode, + callingCode, }; }), [ @@ -65,9 +65,9 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => { return isFocused ? ( - {phones.map(({ number, countryCode }, index) => { + {phones.map(({ number, callingCode }, index) => { const { parsedPhone, invalidPhone } = - parsePhoneNumberOrReturnInvalidValue(countryCode + number); + parsePhoneNumberOrReturnInvalidValue(`+${callingCode}` + number); const URI = parsedPhone?.getURI(); return ( { ) : ( - {phones.map(({ number, countryCode }, index) => { + {phones.map(({ number, callingCode }, index) => { const { parsedPhone, invalidPhone } = - parsePhoneNumberOrReturnInvalidValue(countryCode + number); + parsePhoneNumberOrReturnInvalidValue(`+${callingCode}` + number); const URI = parsedPhone?.getURI(); return ( = { +export type SelectValue = string | number | boolean | null; + +export type SelectProps = { className?: string; disabled?: boolean; selectSizeVariant?: SelectSizeVariant; @@ -57,7 +59,7 @@ const StyledLabel = styled.span` margin-bottom: ${({ theme }) => theme.spacing(1)}; `; -export const Select = ({ +export const Select = ({ className, disabled: disabledFromProps, selectSizeVariant, diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx index 376f738d1..ce9861e92 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/country/components/CountrySelect.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { IconComponentProps } from 'twenty-ui'; +import { IconCircleOff, IconComponentProps } from 'twenty-ui'; import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId'; import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; @@ -15,12 +15,20 @@ export const CountrySelect = ({ const countries = useCountries(); const options: SelectOption[] = useMemo(() => { - return countries.map>(({ countryName, Flag }) => ({ - label: countryName, - value: countryName, - Icon: (props: IconComponentProps) => - Flag({ width: props.size, height: props.size }), // TODO : improve this ? - })); + const countryList = countries.map>( + ({ countryName, Flag }) => ({ + label: countryName, + value: countryName, + Icon: (props: IconComponentProps) => + Flag({ width: props.size, height: props.size }), // TODO : improve this ? + }), + ); + countryList.unshift({ + label: 'No country', + value: '', + Icon: IconCircleOff, + }); + return countryList; }, [countries]); return ( diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/types/Country.ts b/packages/twenty-front/src/modules/ui/input/components/internal/types/Country.ts index 5f4f65689..f315a6849 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/types/Country.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/types/Country.ts @@ -1,8 +1,8 @@ import * as Flags from 'country-flag-icons/react/3x2'; -import { CountryCallingCode } from 'libphonenumber-js'; +import { CountryCallingCode, CountryCode } from 'libphonenumber-js'; export type Country = { - countryCode: string; + countryCode: CountryCode; countryName: string; callingCode: CountryCallingCode; Flag: Flags.FlagComponent; diff --git a/packages/twenty-front/src/utils/string/stripSimpleQuotesFromString.ts b/packages/twenty-front/src/utils/string/stripSimpleQuotesFromString.ts index 44d46d316..3ff8f0a37 100644 --- a/packages/twenty-front/src/utils/string/stripSimpleQuotesFromString.ts +++ b/packages/twenty-front/src/utils/string/stripSimpleQuotesFromString.ts @@ -6,3 +6,20 @@ export const stripSimpleQuotesFromString = ( (simpleQuotesStringSchema.safeParse(value).success ? value.slice(1, -1) : value) as Input extends `'${infer Output}'` ? Output : Input; + +export const stripSimpleQuotesFromStringRecursive = (obj: any): any => { + if (typeof obj === 'string') { + return stripSimpleQuotesFromString(obj); + } else if (Array.isArray(obj)) { + return obj.map(stripSimpleQuotesFromStringRecursive); + } else if (typeof obj === 'object' && obj !== null) { + const newObj: any = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key) === true) { + newObj[key] = stripSimpleQuotesFromStringRecursive(obj[key]); + } + } + return newObj; + } + return obj; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts index 5eec57d2a..e1c29d5fc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts @@ -23,7 +23,7 @@ enum ValueType { NUMBER = 'number', } -class SettingsValidation { +class NumberSettingsValidation { @IsOptional() @IsInt() @Min(0) @@ -32,7 +32,9 @@ class SettingsValidation { @IsOptional() @IsEnum(ValueType) type?: 'percentage' | 'number'; +} +class TextSettingsValidation { @IsOptional() @IsInt() @Min(0) @@ -55,17 +57,19 @@ export class FieldMetadataValidationService< }) { switch (fieldType) { case FieldMetadataType.NUMBER: + await this.validateSettings(NumberSettingsValidation, settings); + break; case FieldMetadataType.TEXT: - await this.validateSettings(settings); + await this.validateSettings(TextSettingsValidation, settings); break; default: break; } } - private async validateSettings(settings: any) { + private async validateSettings(validator: any, settings: any) { try { - const settingsInstance = plainToInstance(SettingsValidation, settings); + const settingsInstance = plainToInstance(validator, settings); await validateOrReject(settingsInstance); } catch (error) { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts new file mode 100644 index 000000000..1e1b0bf63 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/field-metadata-validation.service.spec.ts @@ -0,0 +1,59 @@ +import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; + +import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service'; +import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; + +describe('FieldMetadataValidationService', () => { + let service: FieldMetadataValidationService; + + beforeAll(() => { + service = new FieldMetadataValidationService(); + }); + + it('should validate NUMBER settings successfully', async () => { + const settings = { decimals: 2, type: 'number' } as FieldMetadataSettings; + + await expect( + service.validateSettingsOrThrow({ + fieldType: FieldMetadataType.NUMBER, + settings, + }), + ).resolves.not.toThrow(); + }); + + it('should throw an error for invalid NUMBER settings', async () => { + const settings = { type: 'invalidType' } as FieldMetadataSettings; + + await expect( + service.validateSettingsOrThrow({ + fieldType: FieldMetadataType.NUMBER, + settings, + }), + ).rejects.toThrow(FieldMetadataException); + }); + + it('should validate TEXT settings successfully', async () => { + const settings = { displayedMaxRows: 10 } as FieldMetadataSettings; + + await expect( + service.validateSettingsOrThrow({ + fieldType: FieldMetadataType.TEXT, + settings, + }), + ).resolves.not.toThrow(); + }); + + it('should throw an error for invalid TEXT settings', async () => { + const settings = { + displayedMaxRows: 'NotANumber', + } as FieldMetadataSettings; + + await expect( + service.validateSettingsOrThrow({ + fieldType: FieldMetadataType.TEXT, + settings, + }), + ).rejects.toThrow(FieldMetadataException); + }); +}); diff --git a/packages/twenty-zapier/src/utils/computeInputFields.ts b/packages/twenty-zapier/src/utils/computeInputFields.ts index 3e9cfbe41..080236146 100644 --- a/packages/twenty-zapier/src/utils/computeInputFields.ts +++ b/packages/twenty-zapier/src/utils/computeInputFields.ts @@ -172,7 +172,7 @@ const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => { description: 'Additional Phones', isNullable: true, defaultValue: null, - placeholder: '{ number: "", countryCode: "" }', + placeholder: '{ number: "", callingCode: "" }', list: true, }; return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones];