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 (
-
-
- );
- }}
- />
+ <>
+ {
+ const defaultCountry = value?.addressCountry || '';
+ return (
+
+
+ );
+ }}
+ />
+ {
+ 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';