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
@@ -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,
};
};