feat: add sub fields for address (#13566)

https://github.com/user-attachments/assets/8fe9079d-9b66-4b42-8b11-ad713e9de666

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Naifer
2025-08-02 22:43:39 +01:00
committed by GitHub
parent 4624739879
commit 2bda929984
20 changed files with 1138 additions and 112 deletions
@@ -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 <TextDisplay text={content} />;
const parsedFieldValue = formatAddressDisplay(fieldValue, subFields);
return <TextDisplay text={parsedFieldValue} />;
};
@@ -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}
/>
);
};
@@ -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 & {
@@ -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(),
});
@@ -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<HTMLDivElement>) => void;
Icon?: IconComponent;
};
export type MultiSelectAddressFieldsProps<Value extends SelectValue> = {
className?: string;
disabled?: boolean;
selectSizeVariant?: SelectSizeVariant;
dropdownId: string;
dropdownWidth?: number;
onChange?: (values: Value[]) => void;
options: SelectOption<Value>[];
values: Value[];
callToActionButton?: CallToActionButton;
};
export const MultiSelectAddressFields = <Value extends SelectValue>({
className: _className,
selectSizeVariant,
dropdownId,
dropdownWidth = GenericDropdownContentWidth.Medium,
onChange,
options,
values,
callToActionButton,
}: MultiSelectAddressFieldsProps<Value>) => {
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 (
<Dropdown
dropdownId={dropdownId}
onClose={onCloseDropdown}
clickableComponent={
<SelectControl
selectedOption={{
label:
values?.length === options.length
? 'Default'
: values?.length.toString(),
value: values?.length,
}}
selectSizeVariant={selectSizeVariant}
/>
}
dropdownComponents={
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={selectableItemIdArray}
focusId={dropdownId}
>
<DropdownContent selectDisabled widthInPixels={dropdownWidth}>
<DropdownMenuSearchInput
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptions?.map((option) => {
return (
<SelectableListItem
key={`${option.value}`}
itemId={`${option.value}`}
onEnter={() => {
onChange?.(onOptionSelected(option.value, values));
}}
>
<MenuItemMultiSelectTag
key={`${option.value}`}
selected={values?.includes(option?.value) || false}
text={option.label}
color={'transparent'}
onClick={() =>
onChange?.(onOptionSelected(option.value, values))
}
/>
</SelectableListItem>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight scrollable={false}>
<MenuItem
onClick={callToActionButton?.onClick}
LeftIcon={callToActionButton?.Icon}
text={callToActionButton?.text}
disabled={values.length === options.length}
/>
</DropdownMenuItemsContainer>
</SelectableList>
}
/>
);
};
@@ -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 (
<Controller
name="defaultValue"
defaultValue={{
...defaultDefaultValue,
...fieldMetadataItem?.defaultValue,
}}
control={control}
render={({ field: { onChange, value } }) => {
const defaultCountry = value?.addressCountry || '';
return (
<SettingsOptionCardContentSelect
Icon={IconMap}
title={t`Default Country`}
description={t`The default country for new addresses`}
>
<Select<string>
dropdownWidth={220}
disabled={disabled}
dropdownId="selectDefaultCountry"
value={stripSimpleQuotesFromString(defaultCountry)}
onChange={(newCountry) =>
onChange({
...value,
addressCountry: applySimpleQuotesToString(newCountry),
})
}
options={countries}
selectSizeVariant="small"
withSearchInput={true}
/>
</SettingsOptionCardContentSelect>
);
}}
/>
<>
<Controller
name="defaultValue"
defaultValue={{
...defaultDefaultValue,
...fieldMetadataItem?.defaultValue,
}}
control={control}
render={({ field: { onChange, value } }) => {
const defaultCountry = value?.addressCountry || '';
return (
<SettingsOptionCardContentSelect
Icon={IconMap}
title={t`Default Country`}
description={t`The default country for new addresses`}
>
<Select<string>
dropdownWidth={220}
disabled={disabled}
dropdownId="selectDefaultCountry"
value={stripSimpleQuotesFromString(defaultCountry)}
onChange={(newCountry) =>
onChange({
...value,
addressCountry: applySimpleQuotesToString(newCountry),
})
}
options={countries}
selectSizeVariant="small"
withSearchInput={true}
/>
</SettingsOptionCardContentSelect>
);
}}
/>
<Controller
name="settings.subFields"
defaultValue={initialDisplaySubFields}
control={control}
render={({ field: { onChange, value } }) => {
const values = value ?? [];
return (
<SettingsOptionCardContentSelect
Icon={IconList}
title={t`Sub-Fields`}
description={t`Decide which Sub-address fields you want to display`}
>
<MultiSelectAddressFields<string>
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<HTMLDivElement>) => {
event.preventDefault();
reset();
},
Icon: IconRefresh,
}}
/>
</SettingsOptionCardContentSelect>
);
}}
/>
</>
);
};
@@ -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<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -26,11 +31,25 @@ export const SettingsDataModelFieldAddressSettingsFormCard = ({
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldAddressSettingsFormCardProps) => {
const { initialDisplaySubFields } = useAddressSettingsFormInitialValues({
fieldMetadataItem,
});
const { watch: watchFormValue } =
useFormContext<SettingsDataModelFieldTextFormValues>();
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
fieldMetadataItem={{
...fieldMetadataItem,
settings: {
...fieldMetadataItem.settings,
subFields: watchFormValue(
'settings.subFields',
initialDisplaySubFields,
),
},
}}
objectMetadataItem={objectMetadataItem}
/>
}
@@ -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<typeof msg>;
}[] = [
{
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`,
},
];
@@ -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<FieldMetadataItem, 'settings'> = {
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<FieldMetadataItem, 'settings'> = {
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<FieldMetadataItem, 'settings'> = {
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<FieldMetadataItem, 'settings'> = {
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<FieldMetadataItem, 'settings'> = {
settings: {
subFields: ['addressStreet1', 'addressCity'],
},
};
const { result } = renderHook(() =>
useAddressSettingsFormInitialValues({ fieldMetadataItem }),
);
expect(result.current.initialDisplaySubFields).toEqual([
'addressStreet1',
'addressCity',
]);
});
});
@@ -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<FieldMetadataItem, 'settings'>;
}) => {
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<SettingsDataModelFieldTextFormValues>();
const resetDefaultValueField = () => {
resetField('settings.subFields', {
defaultValue: allAddressSubFields,
});
};
return {
initialDisplaySubFields,
resetDefaultValueField,
};
};
@@ -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 (
<StyledAddressContainer ref={wrapperRef}>
{renderInputWithAutocomplete(
<TextInputV2
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs.addressStreet1}
label="Address 1"
fullWidth
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
textClickOutsideId={
validAutocompleteData &&
typeOfAddressForAutocomplete === 'addressStreet1'
? TEXT_INPUT_CLICK_OUTSIDE_ID
: undefined
}
/>,
'addressStreet1',
)}
<TextInputV2
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs.addressStreet2}
label="Address 2"
fullWidth
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
/>
<StyledHalfRowContainer>
{renderInputWithAutocomplete(
{isFieldInputInSubFieldsAddress('addressStreet1') &&
renderInputWithAutocomplete(
<TextInputV2
value={internalValue.addressCity ?? ''}
ref={inputRefs.addressCity}
label="City"
autoFocus
value={internalValue.addressStreet1 ?? ''}
ref={inputRefs.addressStreet1}
label="Address 1"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
onChange={getChangeHandler('addressStreet1')}
onFocus={getFocusHandler('addressStreet1')}
textClickOutsideId={
validAutocompleteData &&
typeOfAddressForAutocomplete === 'addressCity'
typeOfAddressForAutocomplete === 'addressStreet1'
? TEXT_INPUT_CLICK_OUTSIDE_ID
: undefined
}
/>,
'addressCity',
'addressStreet1',
)}
{isFieldInputInSubFieldsAddress('addressStreet2') && (
<TextInputV2
value={internalValue.addressState ?? ''}
ref={inputRefs.addressState}
label="State"
value={internalValue.addressStreet2 ?? ''}
ref={inputRefs.addressStreet2}
label="Address 2"
fullWidth
onChange={getChangeHandler('addressState')}
onFocus={getFocusHandler('addressState')}
onChange={getChangeHandler('addressStreet2')}
onFocus={getFocusHandler('addressStreet2')}
/>
)}
<StyledHalfRowContainer>
{isFieldInputInSubFieldsAddress('addressCity') &&
renderInputWithAutocomplete(
<TextInputV2
value={internalValue.addressCity ?? ''}
ref={inputRefs.addressCity}
label="City"
fullWidth
onChange={getChangeHandler('addressCity')}
onFocus={getFocusHandler('addressCity')}
textClickOutsideId={
validAutocompleteData &&
typeOfAddressForAutocomplete === 'addressCity'
? TEXT_INPUT_CLICK_OUTSIDE_ID
: undefined
}
/>,
'addressCity',
)}
{isFieldInputInSubFieldsAddress('addressState') && (
<TextInputV2
value={internalValue.addressState ?? ''}
ref={inputRefs.addressState}
label="State"
fullWidth
onChange={getChangeHandler('addressState')}
onFocus={getFocusHandler('addressState')}
/>
)}
</StyledHalfRowContainer>
<StyledHalfRowContainer>
<TextInputV2
value={internalValue.addressPostcode ?? ''}
ref={inputRefs.addressPostcode}
label="Post Code"
fullWidth
onChange={getChangeHandler('addressPostcode')}
onFocus={getFocusHandler('addressPostcode')}
/>
<CountrySelect
label="Country"
onChange={getChangeHandler('addressCountry')}
selectedCountryName={internalValue.addressCountry ?? ''}
/>
{isFieldInputInSubFieldsAddress('addressPostcode') && (
<TextInputV2
value={internalValue.addressPostcode ?? ''}
ref={inputRefs.addressPostcode}
label="Post Code"
fullWidth
onChange={getChangeHandler('addressPostcode')}
onFocus={getFocusHandler('addressPostcode')}
/>
)}
{isFieldInputInSubFieldsAddress('addressCountry') && (
<CountrySelect
label="Country"
onChange={getChangeHandler('addressCountry')}
selectedCountryName={internalValue.addressCountry ?? ''}
/>
)}
</StyledHalfRowContainer>
</StyledAddressContainer>
);
@@ -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,
);
});
});
@@ -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('');
});
});
@@ -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');
});
});
@@ -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);
};
@@ -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(',');
};
@@ -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 =
@@ -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;
}
@@ -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];
@@ -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';