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:
+7
-12
@@ -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} />;
|
||||
};
|
||||
|
||||
+5
-1
@@ -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 & {
|
||||
|
||||
+9
@@ -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(),
|
||||
});
|
||||
|
||||
+132
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+96
-37
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+22
-3
@@ -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}
|
||||
/>
|
||||
}
|
||||
|
||||
+32
@@ -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`,
|
||||
},
|
||||
];
|
||||
+152
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+32
@@ -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>
|
||||
);
|
||||
|
||||
+280
@@ -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(',');
|
||||
};
|
||||
+9
-1
@@ -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 =
|
||||
|
||||
+19
-2
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user