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:
+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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user