From 2bda929984bd21b827b2dcde31539ee645cc705c Mon Sep 17 00:00:00 2001 From: Naifer <161821705+omarNaifer12@users.noreply.github.com> Date: Sat, 2 Aug 2025 22:43:39 +0100 Subject: [PATCH] feat: add sub fields for address (#13566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/8fe9079d-9b66-4b42-8b11-ad713e9de666 --------- Co-authored-by: Félix Malfait --- .../components/AddressFieldDisplay.tsx | 19 +- .../input/components/AddressFieldInput.tsx | 6 +- .../record-field/types/FieldMetadata.ts | 5 +- .../types/guards/isFieldAddressValue.ts | 9 + .../components/MultiSelectAddressFields.tsx | 132 +++++++++ .../SettingsDataModelFieldAddressForm.tsx | 133 ++++++--- ...sDataModelFieldAddressSettingsFormCard.tsx | 25 +- .../DefaultSelectionAddressWithMessages.ts | 32 ++ ...eAddressSettingsFormInitialValues.test.tsx | 152 ++++++++++ .../useAddressSettingsFormInitialValues.ts | 32 ++ .../field/input/components/AddressInput.tsx | 137 +++++---- .../__tests__/useAddressAutocomplete.test.tsx | 280 ++++++++++++++++++ .../__tests__/formatAddressDisplay.test.ts | 95 ++++++ .../__tests__/joinAddressFieldValues.test.ts | 115 +++++++ .../src/utils/formatAddressDisplay.ts | 20 ++ .../src/utils/joinAddressFieldValues.ts | 13 + .../field-metadata-settings.interface.ts | 10 +- .../field-metadata-validation.service.ts | 21 +- .../src/types/AddressFieldsType.ts | 12 + packages/twenty-shared/src/types/index.ts | 2 + 20 files changed, 1138 insertions(+), 112 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/MultiSelectAddressFields.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/__tests__/useAddressSettingsFormInitialValues.test.tsx create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues.ts create mode 100644 packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx create mode 100644 packages/twenty-front/src/utils/__tests__/formatAddressDisplay.test.ts create mode 100644 packages/twenty-front/src/utils/__tests__/joinAddressFieldValues.test.ts create mode 100644 packages/twenty-front/src/utils/formatAddressDisplay.ts create mode 100644 packages/twenty-front/src/utils/joinAddressFieldValues.ts create mode 100644 packages/twenty-shared/src/types/AddressFieldsType.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx index 2f3162517b..c085e9722f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx @@ -1,19 +1,14 @@ -import { isNonEmptyString } from '@sniptt/guards'; - import { useAddressFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useAddressFieldDisplay'; import { TextDisplay } from '@/ui/field/display/components/TextDisplay'; +import { formatAddressDisplay } from '~/utils/formatAddressDisplay'; export const AddressFieldDisplay = () => { - const { fieldValue } = useAddressFieldDisplay(); + const { fieldValue, fieldDefinition } = useAddressFieldDisplay(); + const settings = fieldDefinition.metadata.settings; - const content = [ - fieldValue?.addressStreet1, - fieldValue?.addressStreet2, - fieldValue?.addressCity, - fieldValue?.addressCountry, - ] - .filter(isNonEmptyString) - .join(', '); + const subFields = + settings && 'subFields' in settings ? settings.subFields : undefined; - return ; + const parsedFieldValue = formatAddressDisplay(fieldValue, subFields); + return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx index 6a1975b792..ee9fe3641f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx @@ -25,7 +25,7 @@ export const AddressFieldInput = ({ onTab, onShiftTab, }: AddressFieldInputProps) => { - const { draftValue, setDraftValue } = useAddressField(); + const { draftValue, setDraftValue, fieldDefinition } = useAddressField(); const persistField = usePersistField(); @@ -43,7 +43,10 @@ export const AddressFieldInput = ({ addressLng: newAddress?.addressLng ?? null, }; }; + const settings = fieldDefinition.metadata.settings; + const subFields = + settings && 'subFields' in settings ? settings.subFields : undefined; const handleEnter = (newAddress: FieldAddressDraftValue) => { onEnter?.(() => persistField(convertToAddress(newAddress))); }; @@ -85,6 +88,7 @@ export const AddressFieldInput = ({ onChange={handleChange} onTab={handleTab} onShiftTab={handleShiftTab} + subFields={subFields} /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 632cbe9160..40f8249d89 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -1,6 +1,7 @@ import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues'; import { ZodHelperLiteral } from '@/object-record/record-field/types/ZodHelperLiteral'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { AllowedAddressSubField } from 'twenty-shared/src/types/AddressFieldsType'; import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ThemeColor } from 'twenty-ui/theme'; import { z } from 'zod'; @@ -114,7 +115,9 @@ export type FieldRatingMetadata = BaseFieldMetadata & { export type FieldAddressMetadata = BaseFieldMetadata & { placeHolder: string; - settings?: null; + settings?: { + subFields?: AllowedAddressSubField[] | null; + }; }; export type FieldRawJsonMetadata = BaseFieldMetadata & { 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 1524f4bd2d..3f53a83f89 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 @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { ALLOWED_ADDRESS_SUBFIELDS } from 'twenty-shared/src/types/AddressFieldsType'; import { FieldAddressValue } from '../FieldMetadata'; export const addressSchema = z.object({ @@ -17,3 +18,11 @@ export const isFieldAddressValue = ( fieldValue: unknown, ): fieldValue is FieldAddressValue => addressSchema.safeParse(fieldValue).success; + +export const addressSettingsSchema = z.object({ + subFields: z + .array(z.enum(ALLOWED_ADDRESS_SUBFIELDS)) + .min(1) + .optional() + .nullable(), +}); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/MultiSelectAddressFields.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/MultiSelectAddressFields.tsx new file mode 100644 index 0000000000..55d049794b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/components/MultiSelectAddressFields.tsx @@ -0,0 +1,132 @@ +import { SelectValue } from '@/ui/input/components/internal/select/types'; +import { SelectSizeVariant } from '@/ui/input/components/Select'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { MouseEvent, useMemo, useState } from 'react'; +import { IconComponent } from 'twenty-ui/display'; +import { SelectOption } from 'twenty-ui/input'; +import { MenuItem, MenuItemMultiSelectTag } from 'twenty-ui/navigation'; + +type CallToActionButton = { + text: string; + onClick: (event: MouseEvent) => void; + Icon?: IconComponent; +}; + +export type MultiSelectAddressFieldsProps = { + className?: string; + disabled?: boolean; + selectSizeVariant?: SelectSizeVariant; + dropdownId: string; + dropdownWidth?: number; + onChange?: (values: Value[]) => void; + options: SelectOption[]; + values: Value[]; + callToActionButton?: CallToActionButton; +}; + +export const MultiSelectAddressFields = ({ + className: _className, + selectSizeVariant, + dropdownId, + dropdownWidth = GenericDropdownContentWidth.Medium, + onChange, + options, + values, + callToActionButton, +}: MultiSelectAddressFieldsProps) => { + const [searchInputValue, setSearchInputValue] = useState(''); + const filteredOptions = useMemo( + () => + searchInputValue + ? options.filter(({ label }) => + label.toLowerCase().includes(searchInputValue.toLowerCase()), + ) + : options, + [options, searchInputValue], + ); + const onOptionSelected = (value: Value, values: Value[]) => { + if (!values.includes(value)) { + return [...values, value]; + } else { + return values.filter((val) => val !== value); + } + }; + const selectableItemIdArray = filteredOptions.map((option) => option.label); + const onCloseDropdown = () => { + setSearchInputValue(''); + }; + return ( + + } + dropdownComponents={ + + + setSearchInputValue(event.target.value)} + autoFocus + /> + + + {filteredOptions?.map((option) => { + return ( + { + onChange?.(onOptionSelected(option.value, values)); + }} + > + + onChange?.(onOptionSelected(option.value, values)) + } + /> + + ); + })} + + + + + + + + } + /> + ); +}; 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 index 48b969b77e..52324adb05 100644 --- 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 @@ -1,16 +1,30 @@ 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 { + addressSchema as addressFieldDefaultValueSchema, + addressSettingsSchema, +} from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; +import { MultiSelectAddressFields } from '@/settings/data-model/fields/forms/address/components/MultiSelectAddressFields'; +import { DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES } from '@/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages'; +import { useAddressSettingsFormInitialValues } from '@/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues'; import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; import { Select } from '@/ui/input/components/Select'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useLingui } from '@lingui/react/macro'; +import { MouseEvent } from 'react'; +import { + IconCircleOff, + IconComponentProps, + IconList, + IconMap, + IconRefresh, +} from 'twenty-ui/display'; +import { SelectOption } from 'twenty-ui/input'; import { z } from 'zod'; import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; -import { IconCircleOff, IconComponentProps, IconMap } from 'twenty-ui/display'; -import { SelectOption } from 'twenty-ui/input'; type SettingsDataModelFieldAddressFormProps = { disabled?: boolean; @@ -23,6 +37,7 @@ type SettingsDataModelFieldAddressFormProps = { export const settingsDataModelFieldAddressFormSchema = z.object({ defaultValue: addressFieldDefaultValueSchema, + settings: addressSettingsSchema, }); export type SettingsDataModelFieldTextFormValues = z.infer< @@ -50,7 +65,14 @@ export const SettingsDataModelFieldAddressForm = ({ Flag({ width: props.size, height: props.size }), })), ]; + const { initialDisplaySubFields, resetDefaultValueField } = + useAddressSettingsFormInitialValues({ fieldMetadataItem }); + const { closeDropdown } = useCloseDropdown(); + const reset = () => { + resetDefaultValueField(); + closeDropdown('addressSubFieldsId'); + }; const defaultDefaultValue = { addressStreet1: "''", addressStreet2: null, @@ -63,39 +85,76 @@ export const SettingsDataModelFieldAddressForm = ({ }; return ( - { - const defaultCountry = value?.addressCountry || ''; - return ( - - - dropdownWidth={220} - disabled={disabled} - dropdownId="selectDefaultCountry" - value={stripSimpleQuotesFromString(defaultCountry)} - onChange={(newCountry) => - onChange({ - ...value, - addressCountry: applySimpleQuotesToString(newCountry), - }) - } - options={countries} - selectSizeVariant="small" - withSearchInput={true} - /> - - ); - }} - /> + <> + { + const defaultCountry = value?.addressCountry || ''; + return ( + + + dropdownWidth={220} + disabled={disabled} + dropdownId="selectDefaultCountry" + value={stripSimpleQuotesFromString(defaultCountry)} + onChange={(newCountry) => + onChange({ + ...value, + addressCountry: applySimpleQuotesToString(newCountry), + }) + } + options={countries} + selectSizeVariant="small" + withSearchInput={true} + /> + + ); + }} + /> + { + const values = value ?? []; + return ( + + + options={DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES.map( + (option) => ({ + ...option, + label: t(option.label), + }), + )} + values={values} + dropdownId={'addressSubFieldsId'} + onChange={onChange} + callToActionButton={{ + text: t`Reset to default`, + onClick: (event: MouseEvent) => { + event.preventDefault(); + reset(); + }, + Icon: IconRefresh, + }} + /> + + ); + }} + /> + ); }; 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 index ed7aba3ee1..370a8e1bff 100644 --- 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 @@ -3,17 +3,22 @@ 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 { + SettingsDataModelFieldAddressForm, + SettingsDataModelFieldTextFormValues, +} from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm'; +import { useAddressSettingsFormInitialValues } from '@/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues'; import { SettingsDataModelFieldPreviewCard, SettingsDataModelFieldPreviewCardProps, } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; +import { useFormContext } from 'react-hook-form'; type SettingsDataModelFieldAddressSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'defaultValue' + 'icon' | 'label' | 'type' | 'defaultValue' | 'settings' >; } & Pick; @@ -26,11 +31,25 @@ export const SettingsDataModelFieldAddressSettingsFormCard = ({ fieldMetadataItem, objectMetadataItem, }: SettingsDataModelFieldAddressSettingsFormCardProps) => { + const { initialDisplaySubFields } = useAddressSettingsFormInitialValues({ + fieldMetadataItem, + }); + const { watch: watchFormValue } = + useFormContext(); return ( } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages.ts new file mode 100644 index 0000000000..c5c5f12ba5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages.ts @@ -0,0 +1,32 @@ +import { msg } from '@lingui/core/macro'; +import { AllowedAddressSubField } from 'twenty-shared/src/types/AddressFieldsType'; + +export const DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES: { + value: AllowedAddressSubField; + label: ReturnType; +}[] = [ + { + value: 'addressStreet1', + label: msg`Address 1`, + }, + { + value: 'addressStreet2', + label: msg`Address 2`, + }, + { + value: 'addressCity', + label: msg`City`, + }, + { + value: 'addressState', + label: msg`State`, + }, + { + value: 'addressPostcode', + label: msg`Postcode`, + }, + { + value: 'addressCountry', + label: msg`Country`, + }, +]; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/__tests__/useAddressSettingsFormInitialValues.test.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/__tests__/useAddressSettingsFormInitialValues.test.tsx new file mode 100644 index 0000000000..71d5936bf6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/__tests__/useAddressSettingsFormInitialValues.test.tsx @@ -0,0 +1,152 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { renderHook } from '@testing-library/react'; +import { useFormContext } from 'react-hook-form'; +import { useAddressSettingsFormInitialValues } from '../useAddressSettingsFormInitialValues'; + +jest.mock('react-hook-form', () => ({ + useFormContext: jest.fn(), +})); + +const mockResetField = jest.fn(); +const mockUseFormContext = useFormContext as jest.MockedFunction< + typeof useFormContext +>; + +describe('useAddressSettingsFormInitialValues', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseFormContext.mockReturnValue({ + resetField: mockResetField, + } as any); + }); + + it('should return all address subfields when no fieldMetadataItem is provided', () => { + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({}), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + }); + + it('should return all address subfields when fieldMetadataItem has no settings', () => { + const fieldMetadataItem: Pick = { + settings: undefined, + }; + + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({ fieldMetadataItem }), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + }); + + it('should return all address subfields when settings.subFields is null', () => { + const fieldMetadataItem: Pick = { + settings: { + subFields: null, + }, + }; + + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({ fieldMetadataItem }), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + }); + + it('should return all address subfields when settings.subFields is empty array', () => { + const fieldMetadataItem: Pick = { + settings: { + subFields: [], + }, + }; + + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({ fieldMetadataItem }), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + }); + + it('should return configured subFields when they exist', () => { + const fieldMetadataItem: Pick = { + settings: { + subFields: ['addressStreet1', 'addressCity', 'addressCountry'], + }, + }; + + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({ fieldMetadataItem }), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressCity', + 'addressCountry', + ]); + }); + + it('should call resetField with all address subFields when resetDefaultValueField is called', () => { + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({}), + ); + + result.current.resetDefaultValueField(); + + expect(mockResetField).toHaveBeenCalledWith('settings.subFields', { + defaultValue: [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ], + }); + }); + + it('should handle partial subFields configuration', () => { + const fieldMetadataItem: Pick = { + settings: { + subFields: ['addressStreet1', 'addressCity'], + }, + }; + + const { result } = renderHook(() => + useAddressSettingsFormInitialValues({ fieldMetadataItem }), + ); + + expect(result.current.initialDisplaySubFields).toEqual([ + 'addressStreet1', + 'addressCity', + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues.ts new file mode 100644 index 0000000000..5ce572287b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/address/hooks/useAddressSettingsFormInitialValues.ts @@ -0,0 +1,32 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { SettingsDataModelFieldTextFormValues } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm'; +import { DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES } from '@/settings/data-model/fields/forms/address/constants/DefaultSelectionAddressWithMessages'; +import { useFormContext } from 'react-hook-form'; + +export const useAddressSettingsFormInitialValues = ({ + fieldMetadataItem, +}: { + fieldMetadataItem?: Pick; +}) => { + const allAddressSubFields = DEFAULT_SELECTION_ADDRESS_WITH_MESSAGES.map( + (selectionAddres) => selectionAddres.value, + ); + const initialDisplaySubFields = + fieldMetadataItem?.settings?.subFields && + fieldMetadataItem?.settings?.subFields?.length > 0 + ? fieldMetadataItem.settings.subFields + : allAddressSubFields; + + const { resetField } = useFormContext(); + + const resetDefaultValueField = () => { + resetField('settings.subFields', { + defaultValue: allAddressSubFields, + }); + }; + + return { + initialDisplaySubFields, + resetDefaultValueField, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx index 4cbbf1c232..df8ec87322 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/AddressInput.tsx @@ -18,6 +18,7 @@ import { isDefined } from 'twenty-shared/utils'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; import { v4 } from 'uuid'; +import { AllowedAddressSubField } from 'twenty-shared/types'; import { useAddressAutocomplete } from '../hooks/useAddressAutocomplete'; import { useCountryUtils } from '../hooks/useCountryUtils'; import { useFocusManagement } from '../hooks/useFocusManagement'; @@ -43,7 +44,7 @@ const StyledAddressContainer = styled.div` const StyledHalfRowContainer = styled.div` display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 8px; @media (max-width: ${MOBILE_VIEWPORT}px) { @@ -72,6 +73,7 @@ export type AddressInputProps = { ) => void; clearable?: boolean; onChange?: (updatedValue: FieldAddressDraftValue) => void; + subFields?: AllowedAddressSubField[] | null; }; export const AddressInput = ({ @@ -83,6 +85,7 @@ export const AddressInput = ({ onEscape, onClickOutside, onChange, + subFields, }: AddressInputProps) => { const [internalValue, setInternalValue] = useState(value); @@ -117,6 +120,16 @@ export const AddressInput = ({ closeDropdownOfAutocomplete, } = useAddressAutocomplete(onChange); + const isFieldInputInSubFieldsAddress = useCallback( + (field: AllowedAddressSubField): boolean => { + if (isDefined(subFields)) { + return subFields.includes(field); + } + return true; + }, + [subFields], + ); + const { getFocusHandler, handleTab, handleShiftTab } = useFocusManagement( inputRefs, internalValue, @@ -126,6 +139,9 @@ export const AddressInput = ({ const getChangeHandler = useCallback( (field: keyof FieldAddressDraftValue) => (updatedAddressPart: string) => { + if (isDefined(subFields) && !subFields.includes(field)) { + return; + } const updatedAddress = { ...internalValue, [field]: updatedAddressPart }; setInternalValue(updatedAddress); onChange?.(updatedAddress); @@ -159,6 +175,7 @@ export const AddressInput = ({ typeOfAddressForAutocomplete, setTypeOfAddressForAutocomplete, getAutocompletePlaceData, + subFields, ], ); @@ -250,7 +267,7 @@ export const AddressInput = ({ ); const renderInputWithAutocomplete = ( - inputElement: React.ReactNode, + inputElement: React.ReactNode | null, fieldType: 'addressStreet1' | 'addressCity', ) => { const shouldShowDropdown = @@ -286,73 +303,83 @@ export const AddressInput = ({ return ( - {renderInputWithAutocomplete( - , - 'addressStreet1', - )} - - - {renderInputWithAutocomplete( + {isFieldInputInSubFieldsAddress('addressStreet1') && + renderInputWithAutocomplete( , - 'addressCity', + 'addressStreet1', )} + {isFieldInputInSubFieldsAddress('addressStreet2') && ( + )} + + {isFieldInputInSubFieldsAddress('addressCity') && + renderInputWithAutocomplete( + , + 'addressCity', + )} + {isFieldInputInSubFieldsAddress('addressState') && ( + + )} - - + {isFieldInputInSubFieldsAddress('addressPostcode') && ( + + )} + {isFieldInputInSubFieldsAddress('addressCountry') && ( + + )} ); diff --git a/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx b/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx new file mode 100644 index 0000000000..b0bf78cccd --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/input/hooks/__tests__/useAddressAutocomplete.test.tsx @@ -0,0 +1,280 @@ +import { useGetPlaceApiData } from '@/geo-map/hooks/useGetPlaceApiData'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { act, renderHook } from '@testing-library/react'; +import { useAddressAutocomplete } from '../useAddressAutocomplete'; +import { useCountryUtils } from '../useCountryUtils'; + +// Mock dependencies +jest.mock('@/geo-map/hooks/useGetPlaceApiData'); +jest.mock('../useCountryUtils'); +jest.mock('@/ui/layout/dropdown/hooks/useOpenDropdown'); +jest.mock('@/ui/layout/dropdown/hooks/useCloseDropdown'); +jest.mock('use-debounce', () => ({ + useDebouncedCallback: (fn: (...args: any[]) => any) => fn, +})); + +const mockGetPlaceAutocompleteData = jest.fn(); +const mockGetPlaceDetailsData = jest.fn(); +const mockFindCountryNameByCountryCode = jest.fn(); +const mockOpenDropdown = jest.fn(); +const mockCloseDropdown = jest.fn(); + +describe('useAddressAutocomplete', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useGetPlaceApiData as jest.Mock).mockReturnValue({ + getPlaceAutocompleteData: mockGetPlaceAutocompleteData, + getPlaceDetailsData: mockGetPlaceDetailsData, + }); + + (useCountryUtils as jest.Mock).mockReturnValue({ + findCountryNameByCountryCode: mockFindCountryNameByCountryCode, + }); + + (useOpenDropdown as jest.Mock).mockReturnValue({ + openDropdown: mockOpenDropdown, + }); + + (useCloseDropdown as jest.Mock).mockReturnValue({ + closeDropdown: mockCloseDropdown, + }); + }); + + it('should initialize with default values', () => { + const { result } = renderHook(() => useAddressAutocomplete()); + + expect(result.current.placeAutocompleteData).toEqual([]); + expect(result.current.tokenForPlaceApi).toBeNull(); + expect(result.current.typeOfAddressForAutocomplete).toBeNull(); + }); + + it('should open dropdown when autocomplete data is available', async () => { + mockGetPlaceAutocompleteData.mockResolvedValue([ + { text: '123 Main St', placeId: 'place1' }, + { text: '456 Oak Ave', placeId: 'place2' }, + ]); + + const { result } = renderHook(() => useAddressAutocomplete()); + + await act(async () => { + await result.current.getAutocompletePlaceData('123 Main', 'token123'); + }); + + expect(mockOpenDropdown).toHaveBeenCalled(); + expect(result.current.placeAutocompleteData).toEqual([ + { text: '123 Main St', placeId: 'place1' }, + { text: '456 Oak Ave', placeId: 'place2' }, + ]); + }); + + it('should close dropdown when no autocomplete data is available', async () => { + mockGetPlaceAutocompleteData.mockResolvedValue([]); + + const { result } = renderHook(() => useAddressAutocomplete()); + + await act(async () => { + await result.current.getAutocompletePlaceData('nonexistent', 'token123'); + }); + + expect(mockCloseDropdown).toHaveBeenCalled(); + }); + + it('should close dropdown when autocomplete data is null', async () => { + mockGetPlaceAutocompleteData.mockResolvedValue(null); + + const { result } = renderHook(() => useAddressAutocomplete()); + + await act(async () => { + await result.current.getAutocompletePlaceData('test', 'token123'); + }); + + expect(mockCloseDropdown).toHaveBeenCalled(); + }); + + it('should autofill inputs from place details', async () => { + const mockOnChange = jest.fn(); + const mockPlaceData = { + city: 'New York', + state: 'NY', + country: 'US', + postcode: '10001', + location: { lat: 40.7128, lng: -74.006 }, + }; + + mockGetPlaceDetailsData.mockResolvedValue(mockPlaceData); + mockFindCountryNameByCountryCode.mockReturnValue('United States'); + + const { result } = renderHook(() => useAddressAutocomplete(mockOnChange)); + + const internalValue = { + addressStreet1: '123 Main St', + addressStreet2: null, + addressCity: null, + addressState: null, + addressCountry: null, + addressPostcode: null, + addressLat: null, + addressLng: null, + }; + + await act(async () => { + await result.current.autoFillInputsFromPlaceDetails( + 'place123', + 'token123', + '123 Main St', + internalValue, + ); + }); + + expect(mockOnChange).toHaveBeenCalledWith({ + addressStreet1: '123 Main St', + addressStreet2: null, + addressCity: 'New York', + addressState: 'NY', + addressCountry: 'United States', + addressPostcode: '10001', + addressLat: 40.7128, + addressLng: -74.006, + }); + }); + + it('should preserve existing values when place data is missing', async () => { + const mockOnChange = jest.fn(); + const mockPlaceData = { + city: null, + state: null, + country: null, + postcode: null, + location: null, + }; + + mockGetPlaceDetailsData.mockResolvedValue(mockPlaceData); + mockFindCountryNameByCountryCode.mockReturnValue(null); + + const { result } = renderHook(() => useAddressAutocomplete(mockOnChange)); + + const internalValue = { + addressStreet1: '123 Main St', + addressStreet2: 'Apt 4B', + addressCity: 'Existing City', + addressState: 'CA', + addressCountry: 'United States', + addressPostcode: '90210', + addressLat: 34.0522, + addressLng: -118.2437, + }; + + await act(async () => { + await result.current.autoFillInputsFromPlaceDetails( + 'place123', + 'token123', + '123 Main St', + internalValue, + ); + }); + + expect(mockOnChange).toHaveBeenCalledWith({ + addressStreet1: '123 Main St', + addressStreet2: 'Apt 4B', + addressCity: 'Existing City', + addressState: 'CA', + addressCountry: 'United States', + addressPostcode: '90210', + addressLat: 34.0522, + addressLng: -118.2437, + }); + }); + + it('should close dropdown after autofilling', async () => { + const mockOnChange = jest.fn(); + mockGetPlaceDetailsData.mockResolvedValue({}); + mockFindCountryNameByCountryCode.mockReturnValue(null); + + const { result } = renderHook(() => useAddressAutocomplete(mockOnChange)); + + await act(async () => { + await result.current.autoFillInputsFromPlaceDetails( + 'place123', + 'token123', + ); + }); + + expect(mockCloseDropdown).toHaveBeenCalled(); + }); + + it('should set token to null after autofilling', async () => { + const mockOnChange = jest.fn(); + mockGetPlaceDetailsData.mockResolvedValue({}); + + const { result } = renderHook(() => useAddressAutocomplete(mockOnChange)); + + // Set initial token + act(() => { + result.current.setTokenForPlaceApi('initial-token'); + }); + + expect(result.current.tokenForPlaceApi).toBe('initial-token'); + + await act(async () => { + await result.current.autoFillInputsFromPlaceDetails( + 'place123', + 'token123', + ); + }); + + expect(result.current.tokenForPlaceApi).toBeNull(); + }); + + it('should handle country code conversion correctly', async () => { + const mockOnChange = jest.fn(); + const mockPlaceData = { + country: 'US', + city: 'Boston', + }; + + mockGetPlaceDetailsData.mockResolvedValue(mockPlaceData); + mockFindCountryNameByCountryCode.mockReturnValue('United States'); + + const { result } = renderHook(() => useAddressAutocomplete(mockOnChange)); + + await act(async () => { + await result.current.autoFillInputsFromPlaceDetails( + 'place123', + 'token123', + ); + }); + + expect(mockFindCountryNameByCountryCode).toHaveBeenCalledWith('US'); + expect(mockOnChange).toHaveBeenCalledWith( + expect.objectContaining({ + addressCountry: 'United States', + }), + ); + }); + + it('should handle address autocomplete with country and isFieldCity parameters', async () => { + mockGetPlaceAutocompleteData.mockResolvedValue([ + { text: 'Boston, MA', placeId: 'place1' }, + ]); + + const { result } = renderHook(() => useAddressAutocomplete()); + + await act(async () => { + await result.current.getAutocompletePlaceData( + 'Boston', + 'token123', + 'US', + true, + ); + }); + + expect(mockGetPlaceAutocompleteData).toHaveBeenCalledWith( + 'Boston', + 'token123', + 'US', + true, + ); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/formatAddressDisplay.test.ts b/packages/twenty-front/src/utils/__tests__/formatAddressDisplay.test.ts new file mode 100644 index 0000000000..14b88d447a --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/formatAddressDisplay.test.ts @@ -0,0 +1,95 @@ +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { formatAddressDisplay } from '../formatAddressDisplay'; + +describe('formatAddressDisplay', () => { + const mockAddressValue: FieldAddressValue = { + addressStreet1: '123 Main St', + addressStreet2: 'Apt 4B', + addressCity: 'New York', + addressState: 'NY', + addressPostcode: '10001', + addressCountry: 'United States', + addressLat: 40.7128, + addressLng: -74.006, + }; + + it('should return empty string when fieldValue is undefined', () => { + const result = formatAddressDisplay(undefined, ['addressStreet1']); + expect(result).toBe(''); + }); + + it('should return empty string when fieldValue is null', () => { + const result = formatAddressDisplay(null as any, ['addressStreet1']); + expect(result).toBe(''); + }); + + it('should format address with specified subFields', () => { + const result = formatAddressDisplay(mockAddressValue, [ + 'addressStreet1', + 'addressCity', + 'addressState', + ]); + expect(result).toBe('123 Main St,New York,NY'); + }); + + it('should format address with all fields when subFields is null', () => { + const result = formatAddressDisplay(mockAddressValue, null); + expect(result).toBe('123 Main St,Apt 4B,New York,NY,10001,United States'); + }); + + it('should format address with all fields when subFields is undefined', () => { + const result = formatAddressDisplay(mockAddressValue, undefined); + expect(result).toBe('123 Main St,Apt 4B,New York,NY,10001,United States'); + }); + + it('should format address with all fields when subFields is empty array', () => { + const result = formatAddressDisplay(mockAddressValue, []); + expect(result).toBe('123 Main St,Apt 4B,New York,NY,10001,United States'); + }); + + it('should handle address with some empty fields', () => { + const partialAddress: FieldAddressValue = { + addressStreet1: '456 Oak Ave', + addressStreet2: null, + addressCity: 'Boston', + addressState: null, + addressPostcode: '02101', + addressCountry: 'United States', + addressLat: null, + addressLng: null, + }; + + const result = formatAddressDisplay(partialAddress, [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + ]); + expect(result).toBe('456 Oak Ave,Boston,02101'); + }); + + it('should handle single field selection', () => { + const result = formatAddressDisplay(mockAddressValue, ['addressCity']); + expect(result).toBe('New York'); + }); + + it('should handle empty address object', () => { + const emptyAddress: FieldAddressValue = { + addressStreet1: '', + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, + addressCountry: null, + addressLat: null, + addressLng: null, + }; + + const result = formatAddressDisplay(emptyAddress, [ + 'addressStreet1', + 'addressCity', + ]); + expect(result).toBe(''); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/joinAddressFieldValues.test.ts b/packages/twenty-front/src/utils/__tests__/joinAddressFieldValues.test.ts new file mode 100644 index 0000000000..7555fc17c5 --- /dev/null +++ b/packages/twenty-front/src/utils/__tests__/joinAddressFieldValues.test.ts @@ -0,0 +1,115 @@ +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { joinAddressFieldValues } from '../joinAddressFieldValues'; + +describe('joinAddressFieldValues', () => { + const mockAddressValue: FieldAddressValue = { + addressStreet1: '123 Main St', + addressStreet2: 'Apt 4B', + addressCity: 'New York', + addressState: 'NY', + addressPostcode: '10001', + addressCountry: 'United States', + addressLat: 40.7128, + addressLng: -74.006, + }; + + it('should join specified address fields with commas', () => { + const result = joinAddressFieldValues(mockAddressValue, [ + 'addressStreet1', + 'addressCity', + 'addressState', + ]); + expect(result).toBe('123 Main St,New York,NY'); + }); + + it('should filter out null and empty string values', () => { + const addressWithNulls: FieldAddressValue = { + addressStreet1: '456 Oak Ave', + addressStreet2: null, + addressCity: '', + addressState: 'CA', + addressPostcode: '90210', + addressCountry: null, + addressLat: null, + addressLng: null, + }; + + const result = joinAddressFieldValues(addressWithNulls, [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + expect(result).toBe('456 Oak Ave,CA,90210'); + }); + + it('should handle empty subFields array', () => { + const result = joinAddressFieldValues(mockAddressValue, []); + expect(result).toBe(''); + }); + + it('should handle single field', () => { + const result = joinAddressFieldValues(mockAddressValue, ['addressCity']); + expect(result).toBe('New York'); + }); + + it('should handle all fields', () => { + const result = joinAddressFieldValues(mockAddressValue, [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + ]); + expect(result).toBe('123 Main St,Apt 4B,New York,NY,10001,United States'); + }); + + it('should handle address with empty and null values', () => { + const emptyAddress: FieldAddressValue = { + addressStreet1: '', + addressStreet2: null, + addressCity: null, + addressState: null, + addressPostcode: null, + addressCountry: null, + addressLat: null, + addressLng: null, + }; + + const result = joinAddressFieldValues(emptyAddress, [ + 'addressStreet1', + 'addressCity', + 'addressCountry', + ]); + expect(result).toBe(''); + }); + + it('should handle numeric values correctly', () => { + const result = joinAddressFieldValues(mockAddressValue, [ + 'addressLat', + 'addressLng', + ]); + // Note: isNonEmptyString from @sniptt/guards only accepts strings, so numeric values are filtered out + expect(result).toBe(''); + }); + + it('should handle mixed null and valid values', () => { + const mixedAddress: FieldAddressValue = { + ...mockAddressValue, + addressStreet2: null, + addressState: null, + }; + + const result = joinAddressFieldValues(mixedAddress, [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + ]); + expect(result).toBe('123 Main St,New York,10001'); + }); +}); diff --git a/packages/twenty-front/src/utils/formatAddressDisplay.ts b/packages/twenty-front/src/utils/formatAddressDisplay.ts new file mode 100644 index 0000000000..c5a485011e --- /dev/null +++ b/packages/twenty-front/src/utils/formatAddressDisplay.ts @@ -0,0 +1,20 @@ +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { + ALLOWED_ADDRESS_SUBFIELDS, + AllowedAddressSubField, +} from 'twenty-shared/src/types/AddressFieldsType'; +import { isDefined } from 'twenty-shared/utils'; +import { joinAddressFieldValues } from '~/utils/joinAddressFieldValues'; + +export const formatAddressDisplay = ( + fieldValue: FieldAddressValue | undefined, + subFields: AllowedAddressSubField[] | null | undefined, +) => { + if (!isDefined(fieldValue)) return ''; + const fieldsToUse = + subFields && subFields.length > 0 + ? subFields + : [...ALLOWED_ADDRESS_SUBFIELDS]; + + return joinAddressFieldValues(fieldValue, fieldsToUse); +}; diff --git a/packages/twenty-front/src/utils/joinAddressFieldValues.ts b/packages/twenty-front/src/utils/joinAddressFieldValues.ts new file mode 100644 index 0000000000..6762e885bc --- /dev/null +++ b/packages/twenty-front/src/utils/joinAddressFieldValues.ts @@ -0,0 +1,13 @@ +import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AllowedAddressSubField } from 'twenty-shared/types'; + +export const joinAddressFieldValues = ( + fieldValue: FieldAddressValue, + subFields: AllowedAddressSubField[], +) => { + return subFields + .map((subField) => fieldValue[subField]) + .filter(isNonEmptyString) + .join(','); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts index 56e1a3e5e0..97406d66fd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts @@ -1,4 +1,8 @@ -import { FieldMetadataType, IsExactly } from 'twenty-shared/types'; +import { + AllowedAddressSubField, + FieldMetadataType, + IsExactly, +} from 'twenty-shared/types'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; @@ -40,6 +44,9 @@ export type FieldMetadataRelationSettings = { onDelete?: RelationOnDeleteAction; joinColumnName?: string | null; }; +export type FieldMetadataAddressSettings = { + subFields?: AllowedAddressSubField[]; +}; type FieldMetadataSettingsMapping = { [FieldMetadataType.NUMBER]: FieldMetadataNumberSettings; @@ -48,6 +55,7 @@ type FieldMetadataSettingsMapping = { [FieldMetadataType.TEXT]: FieldMetadataTextSettings; [FieldMetadataType.RELATION]: FieldMetadataRelationSettings; [FieldMetadataType.MORPH_RELATION]: FieldMetadataRelationSettings; + [FieldMetadataType.ADDRESS]: FieldMetadataAddressSettings; }; export type AllFieldMetadataSettings = diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts index c2c9a43944..fa9961e9e3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { t } from '@lingui/core/macro'; import { ClassConstructor, plainToInstance } from 'class-transformer'; import { + IsArray, IsEnum, IsInt, IsOptional, @@ -11,7 +12,11 @@ import { ValidationError, validateOrReject, } from 'class-validator'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { + ALLOWED_ADDRESS_SUBFIELDS, + AllowedAddressSubField, + FieldMetadataType, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; @@ -61,7 +66,12 @@ class TextSettingsValidation { @Max(100) displayedMaxRows?: number; } - +class AddressSettingsValidation { + @IsOptional() + @IsArray() + @IsEnum(ALLOWED_ADDRESS_SUBFIELDS, { each: true }) + subFields?: AllowedAddressSubField[]; +} @Injectable() export class FieldMetadataValidationService { constructor( @@ -90,6 +100,13 @@ export class FieldMetadataValidationService { settings, }); break; + case FieldMetadataType.ADDRESS: + await this.validateSettings({ + type: FieldMetadataType.ADDRESS, + validator: AddressSettingsValidation, + settings, + }); + break; default: break; } diff --git a/packages/twenty-shared/src/types/AddressFieldsType.ts b/packages/twenty-shared/src/types/AddressFieldsType.ts new file mode 100644 index 0000000000..3e3581eb8b --- /dev/null +++ b/packages/twenty-shared/src/types/AddressFieldsType.ts @@ -0,0 +1,12 @@ +export const ALLOWED_ADDRESS_SUBFIELDS = [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressPostcode', + 'addressCountry', + 'addressLat', + 'addressLng', +] as const; + +export type AllowedAddressSubField = (typeof ALLOWED_ADDRESS_SUBFIELDS)[number]; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index df9b0429b3..be30724edc 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -7,6 +7,8 @@ * |___/ */ +export type { AllowedAddressSubField } from './AddressFieldsType'; +export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType'; export type { ConfigVariableValue } from './ConfigVariableValue'; export { ConnectedAccountProvider } from './ConnectedAccountProvider'; export type { EnumFieldMetadataType } from './EnumFieldMetadataType';