diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts index bfcca728d674..816f95219f40 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts @@ -1,4 +1,5 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { computeMetadataDefaultValue } from '~/pages/settings/data-model/utils/compute-metadata-defaultValue-utils'; import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/compute-metadata-name-from-label.utils'; export const formatFieldMetadataItemInput = ( @@ -18,7 +19,7 @@ export const formatFieldMetadataItemInput = ( const label = input.label?.trim(); return { - defaultValue: input.defaultValue, + defaultValue: computeMetadataDefaultValue(input.defaultValue), description: input.description?.trim() ?? null, icon: input.icon, label, 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 8bc33766e803..1524f4bd2d74 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/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index 47a404873b14..5e080f05fa45 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,6 +1,7 @@ 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'; @@ -42,6 +43,20 @@ export const computeDraftValueFromFieldValue = ({ } as unknown as FieldInputDraftValue; } + if (isFieldAddress(fieldDefinition)) { + if ( + isFieldValueEmpty({ fieldValue, fieldDefinition }) && + !!fieldDefinition?.defaultValue?.addressCountry + ) { + return { + ...fieldValue, + addressCountry: fieldDefinition?.defaultValue?.addressCountry, + } as unknown as FieldInputDraftValue; + } + + return fieldValue as FieldInputDraftValue; + } + if ( isFieldNumber(fieldDefinition) && isFieldNumberValue(fieldValue) && 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 000000000000..6591a9dc3b35 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm.tsx @@ -0,0 +1,81 @@ +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 { removeSingleQuotesFromStrings } from '~/pages/settings/data-model/utils/compute-metadata-defaultValue-utils'; + +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().map((country) => ({ + label: country.countryName, + value: country.countryName, + })); + countries.unshift({ label: 'No country', value: '' }); + const defaultValueInstance = { + addressStreet1: '', + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, + addressCountry: null, + addressLat: null, + addressLng: null, + }; + const fieldMetadataItemDefaultValue = fieldMetadataItem?.defaultValue + ? removeSingleQuotesFromStrings(fieldMetadataItem?.defaultValue) + : fieldMetadataItem?.defaultValue; + + return ( + { + const defaultCountry = value?.addressCountry || ''; + return ( + <> + + onChange({ ...value, addressCountry: newCountry }) + } + disabled={disabled} + options={countries} + /> + + ); + }} + /> + ); +}; 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 000000000000..ed7aba3ee17e --- /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 281c2052286f..14a7a65e02d7 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'; @@ -64,6 +66,10 @@ const textFieldFormSchema = z .object({ type: z.literal(FieldMetadataType.Text) }) .merge(settingsDataModelFieldtextFormSchema); +const addressFieldFormSchema = z + .object({ type: z.literal(FieldMetadataType.Address) }) + .merge(settingsDataModelFieldAddressFormSchema); + const otherFieldsFormSchema = z.object({ type: z.enum( Object.keys( @@ -76,6 +82,7 @@ const otherFieldsFormSchema = z.object({ FieldMetadataType.Date, FieldMetadataType.DateTime, FieldMetadataType.Number, + FieldMetadataType.Address, FieldMetadataType.Text, ]), ) as [FieldMetadataType, ...FieldMetadataType[]], @@ -94,6 +101,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( multiSelectFieldFormSchema, numberFieldFormSchema, textFieldFormSchema, + addressFieldFormSchema, otherFieldsFormSchema, ], ); @@ -200,6 +208,15 @@ export const SettingsDataModelFieldSettingsFormCard = ({ ); } + if (fieldMetadataItem.type === FieldMetadataType.Address) { + return ( + + ); + } + if ( fieldMetadataItem.type === FieldMetadataType.Select || fieldMetadataItem.type === FieldMetadataType.MultiSelect 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 376f738d1f2e..ce9861e9261f 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/pages/settings/data-model/utils/compute-metadata-defaultValue-utils.ts b/packages/twenty-front/src/pages/settings/data-model/utils/compute-metadata-defaultValue-utils.ts new file mode 100644 index 000000000000..4d0e5927dc64 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/data-model/utils/compute-metadata-defaultValue-utils.ts @@ -0,0 +1,57 @@ +export const computeMetadataDefaultValue = (input: any): any => { + if (typeof input !== 'object') { + throw new Error('Input type for DefaultValue is not handled yet'); + } + return addSingleQuotesToStrings(input); +}; + +export const addSingleQuotesToStrings = (obj: any): any => { + if (typeof obj === 'string') { + if (obj === '') { + return "''"; + } + if (obj === "''") { + return "''"; + } + + obj = "'" + obj + "'"; + + if (obj.startsWith("''") === true) { + obj = obj.slice(1); + } + if (obj.endsWith("''") === true) { + obj = obj.slice(1); + } + return obj; + } else if (Array.isArray(obj)) { + return obj.map(addSingleQuotesToStrings); + } 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] = addSingleQuotesToStrings(obj[key]); + } + } + return newObj; + } + return obj; +}; +export const removeSingleQuotesFromStrings = (obj: any): any => { + if (typeof obj === 'string') { + if (obj.startsWith("'") && obj.endsWith("'")) { + return obj.slice(1, -1); + } + return obj; + } else if (Array.isArray(obj)) { + return obj.map(removeSingleQuotesFromStrings); + } 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] = removeSingleQuotesFromStrings(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 5eec57d2afb1..e1c29d5fc0d3 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 000000000000..1e1b0bf63f07 --- /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); + }); +});