Default address country 🗺️ & Phone prefix ☎️ (#8614)

# Default address 🗺️ country & Phone ☎️ country

We add the ability to add a Default address country and a default Phone
country for fields in the Data model.

fix #8081

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Guillim 2024-12-02 13:34:05 +01:00 committed by GitHub
parent 39a9cd0d51
commit 0527bc296e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 617 additions and 108 deletions

View File

@ -2,5 +2,6 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [ export const FIELD_NOT_OVERWRITTEN_AT_DRAFT = [
FieldMetadataType.Address, FieldMetadataType.Address,
FieldMetadataType.Phones,
FieldMetadataType.Links, FieldMetadataType.Links,
]; ];

View File

@ -35,8 +35,8 @@ export const useNumberField = () => {
const persistNumberField = (newValue: string) => { const persistNumberField = (newValue: string) => {
if (fieldDefinition?.metadata?.settings?.type === 'percentage') { if (fieldDefinition?.metadata?.settings?.type === 'percentage') {
newValue = newValue.replaceAll('%', ''); const newValueEscaped = newValue.replaceAll('%', '');
if (!canBeCastAsNumberOrNull(newValue)) { if (!canBeCastAsNumberOrNull(newValueEscaped)) {
return; return;
} }
const castedValue = castAsNumberOrNull(newValue); const castedValue = castAsNumberOrNull(newValue);

View File

@ -111,7 +111,7 @@ export const MultiItemFieldInput = <T,>({
break; break;
case FieldMetadataType.Phones: case FieldMetadataType.Phones:
item = items[index] as PhoneRecord; item = items[index] as PhoneRecord;
setInputValue(item.countryCode + item.number); setInputValue(`+${item.callingCode}` + item.number);
break; break;
case FieldMetadataType.Emails: case FieldMetadataType.Emails:
item = items[index] as string; item = items[index] as string;

View File

@ -5,12 +5,14 @@ import { E164Number, parsePhoneNumber } from 'libphonenumber-js';
import { useMemo } from 'react'; import { useMemo } from 'react';
import ReactPhoneNumberInput from 'react-phone-number-input'; import ReactPhoneNumberInput from 'react-phone-number-input';
import 'react-phone-number-input/style.css'; import 'react-phone-number-input/style.css';
import { isDefined, TEXT_INPUT_STYLE } from 'twenty-ui'; import { TEXT_INPUT_STYLE, isDefined } from 'twenty-ui';
import { MultiItemFieldInput } from './MultiItemFieldInput'; import { MultiItemFieldInput } from './MultiItemFieldInput';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton'; import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)` const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family}; font-family: ${({ theme }) => theme.font.family};
@ -48,33 +50,41 @@ type PhonesFieldInputProps = {
}; };
export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => { export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => {
const { persistPhonesField, hotkeyScope, fieldValue } = usePhonesField(); const { persistPhonesField, hotkeyScope, draftValue, fieldDefinition } =
usePhonesField();
const phones = useMemo<{ number: string; countryCode: string }[]>( const phones = useMemo<{ number: string; callingCode: string }[]>(() => {
() => if (!isDefined(draftValue)) {
[ return [];
fieldValue.primaryPhoneNumber }
? { return [
number: fieldValue.primaryPhoneNumber, draftValue.primaryPhoneNumber
countryCode: fieldValue.primaryPhoneCountryCode, ? {
} number: draftValue.primaryPhoneNumber,
: null, callingCode: draftValue.primaryPhoneCountryCode,
...(fieldValue.additionalPhones ?? []), }
].filter(isDefined), : null,
[ ...(draftValue.additionalPhones ?? []),
fieldValue.primaryPhoneNumber, ].filter(isDefined);
fieldValue.primaryPhoneCountryCode, }, [draftValue]);
fieldValue.additionalPhones,
], const defaultCallingCode =
); stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
) ?? '+1';
// TODO : improve once we store the real country code
const defaultCountry = useCountries().find(
(obj) => obj.callingCode === defaultCallingCode,
)?.countryCode;
const handlePersistPhones = ( const handlePersistPhones = (
updatedPhones: { number: string; countryCode: string }[], updatedPhones: { number: string; callingCode: string }[],
) => { ) => {
const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones; const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones;
persistPhonesField({ persistPhonesField({
primaryPhoneNumber: nextPrimaryPhone?.number ?? '', primaryPhoneNumber: nextPrimaryPhone?.number ?? '',
primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '', primaryPhoneCountryCode: nextPrimaryPhone?.callingCode ?? '',
additionalPhones: nextAdditionalPhones, additionalPhones: nextAdditionalPhones,
}); });
}; };
@ -93,12 +103,12 @@ export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => {
if (phone !== undefined) { if (phone !== undefined) {
return { return {
number: phone.nationalNumber, number: phone.nationalNumber,
countryCode: `+${phone.countryCallingCode}`, callingCode: `${phone.countryCallingCode}`,
}; };
} }
return { return {
number: '', number: '',
countryCode: '', callingCode: '',
}; };
}} }}
renderItem={({ renderItem={({
@ -128,6 +138,7 @@ export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => {
international={true} international={true}
withCountryCallingCode={true} withCountryCallingCode={true}
countrySelectComponent={PhoneCountryPickerDropdownButton} countrySelectComponent={PhoneCountryPickerDropdownButton}
defaultCountry={defaultCountry}
/> />
); );
}} }}

View File

@ -7,7 +7,7 @@ type PhonesFieldMenuItemProps = {
onEdit?: () => void; onEdit?: () => void;
onSetAsPrimary?: () => void; onSetAsPrimary?: () => void;
onDelete?: () => void; onDelete?: () => void;
phone: { number: string; countryCode: string }; phone: { number: string; callingCode: string };
}; };
export const PhonesFieldMenuItem = ({ export const PhonesFieldMenuItem = ({
@ -22,7 +22,7 @@ export const PhonesFieldMenuItem = ({
<MultiItemFieldMenuItem <MultiItemFieldMenuItem
dropdownId={dropdownId} dropdownId={dropdownId}
isPrimary={isPrimary} isPrimary={isPrimary}
value={phone.countryCode + phone.number} value={{ number: phone.number, callingCode: phone.callingCode }}
onEdit={onEdit} onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary} onSetAsPrimary={onSetAsPrimary}
onDelete={onDelete} onDelete={onDelete}

View File

@ -10,13 +10,13 @@ import { CurrencyCode } from './CurrencyCode';
export type FieldUuidMetadata = { export type FieldUuidMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldBooleanMetadata = { export type FieldBooleanMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldTextMetadata = { export type FieldTextMetadata = {
@ -61,13 +61,13 @@ export type FieldLinkMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldLinksMetadata = { export type FieldLinksMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldCurrencyMetadata = { export type FieldCurrencyMetadata = {
@ -75,66 +75,66 @@ export type FieldCurrencyMetadata = {
fieldName: string; fieldName: string;
placeHolder: string; placeHolder: string;
isPositive?: boolean; isPositive?: boolean;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldFullNameMetadata = { export type FieldFullNameMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldEmailMetadata = { export type FieldEmailMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldEmailsMetadata = { export type FieldEmailsMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldPhoneMetadata = { export type FieldPhoneMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldRatingMetadata = { export type FieldRatingMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldAddressMetadata = { export type FieldAddressMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldRawJsonMetadata = { export type FieldRawJsonMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
placeHolder: string; placeHolder: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldRichTextMetadata = { export type FieldRichTextMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldPositionMetadata = { export type FieldPositionMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldRelationMetadata = { export type FieldRelationMetadata = {
@ -146,7 +146,7 @@ export type FieldRelationMetadata = {
relationType?: RelationDefinitionType; relationType?: RelationDefinitionType;
targetFieldMetadataName?: string; targetFieldMetadataName?: string;
useEditButton?: boolean; useEditButton?: boolean;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldSelectMetadata = { export type FieldSelectMetadata = {
@ -154,39 +154,39 @@ export type FieldSelectMetadata = {
fieldName: string; fieldName: string;
options: { label: string; color: ThemeColor; value: string }[]; options: { label: string; color: ThemeColor; value: string }[];
isNullable: boolean; isNullable: boolean;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldMultiSelectMetadata = { export type FieldMultiSelectMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
options: { label: string; color: ThemeColor; value: string }[]; options: { label: string; color: ThemeColor; value: string }[];
settings?: Record<string, never>; settings?: null;
}; };
export type FieldActorMetadata = { export type FieldActorMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldArrayMetadata = { export type FieldArrayMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
values: { label: string; value: string }[]; values: { label: string; value: string }[];
settings?: Record<string, never>; settings?: null;
}; };
export type FieldPhonesMetadata = { export type FieldPhonesMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldTsVectorMetadata = { export type FieldTsVectorMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
fieldName: string; fieldName: string;
settings?: Record<string, never>; settings?: null;
}; };
export type FieldMetadata = export type FieldMetadata =
@ -265,7 +265,7 @@ export type FieldActorValue = {
export type FieldArrayValue = string[]; export type FieldArrayValue = string[];
export type PhoneRecord = { number: string; countryCode: string }; export type PhoneRecord = { number: string; callingCode: string };
export type FieldPhonesValue = { export type FieldPhonesValue = {
primaryPhoneNumber: string; primaryPhoneNumber: string;

View File

@ -2,7 +2,7 @@ import { z } from 'zod';
import { FieldAddressValue } from '../FieldMetadata'; import { FieldAddressValue } from '../FieldMetadata';
const addressSchema = z.object({ export const addressSchema = z.object({
addressStreet1: z.string(), addressStreet1: z.string(),
addressStreet2: z.string().nullable(), addressStreet2: z.string().nullable(),
addressCity: z.string().nullable(), addressCity: z.string().nullable(),

View File

@ -6,7 +6,7 @@ export const phonesSchema = z.object({
primaryPhoneNumber: z.string(), primaryPhoneNumber: z.string(),
primaryPhoneCountryCode: z.string(), primaryPhoneCountryCode: z.string(),
additionalPhones: z additionalPhones: z
.array(z.object({ number: z.string(), countryCode: z.string() })) .array(z.object({ number: z.string(), callingCode: z.string() }))
.nullable(), .nullable(),
}) satisfies z.ZodType<FieldPhonesValue>; }) satisfies z.ZodType<FieldPhonesValue>;

View File

@ -1,10 +1,12 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldNumberValue } from '@/object-record/record-field/types/guards/isFieldNumberValue'; import { isFieldNumberValue } from '@/object-record/record-field/types/guards/isFieldNumberValue';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
@ -12,6 +14,7 @@ import { computeEmptyDraftValue } from '@/object-record/record-field/utils/compu
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty'; import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type computeDraftValueFromFieldValueParams<FieldValue> = { type computeDraftValueFromFieldValueParams<FieldValue> = {
fieldDefinition: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>; fieldDefinition: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>;
@ -42,6 +45,38 @@ export const computeDraftValueFromFieldValue = <FieldValue>({
} as unknown as FieldInputDraftValue<FieldValue>; } as unknown as FieldInputDraftValue<FieldValue>;
} }
if (isFieldAddress(fieldDefinition)) {
if (
isFieldValueEmpty({ fieldValue, fieldDefinition }) &&
!!fieldDefinition?.defaultValue?.addressCountry
) {
return {
...fieldValue,
addressCountry: stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.addressCountry,
),
} as unknown as FieldInputDraftValue<FieldValue>;
}
return fieldValue as FieldInputDraftValue<FieldValue>;
}
if (isFieldPhones(fieldDefinition)) {
if (
isFieldValueEmpty({ fieldValue, fieldDefinition }) &&
!!fieldDefinition?.defaultValue?.primaryPhoneCountryCode
) {
return {
...fieldValue,
primaryPhoneCountryCode: stripSimpleQuotesFromString(
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
),
} as unknown as FieldInputDraftValue<FieldValue>;
}
return fieldValue as FieldInputDraftValue<FieldValue>;
}
if ( if (
isFieldNumber(fieldDefinition) && isFieldNumber(fieldDefinition) &&
isFieldNumberValue(fieldValue) && isFieldNumberValue(fieldValue) &&

View File

@ -5,17 +5,10 @@ import {
StyledSettingsOptionCardTitle, StyledSettingsOptionCardTitle,
} from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase'; } from '@/settings/components/SettingsOptions/SettingsOptionCardContentBase';
import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer'; import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOptions/SettingsOptionIconCustomizer';
import { Select } from '@/ui/input/components/Select'; import { Select, SelectValue } from '@/ui/input/components/Select';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui'; import { IconComponent } from 'twenty-ui';
const StyledSettingsOptionCardSelect = styled(Select)`
margin-left: auto;
width: 120px;
`;
type SelectValue = string | number | boolean | null;
type SettingsOptionCardContentSelectProps<Value extends SelectValue> = { type SettingsOptionCardContentSelectProps<Value extends SelectValue> = {
Icon?: IconComponent; Icon?: IconComponent;
title: React.ReactNode; title: React.ReactNode;
@ -23,7 +16,7 @@ type SettingsOptionCardContentSelectProps<Value extends SelectValue> = {
divider?: boolean; divider?: boolean;
disabled?: boolean; disabled?: boolean;
value: Value; value: Value;
onChange: (value: SelectValue) => void; onChange: (value: Value) => void;
options: { options: {
value: Value; value: Value;
label: string; label: string;
@ -34,6 +27,10 @@ type SettingsOptionCardContentSelectProps<Value extends SelectValue> = {
fullWidth?: boolean; fullWidth?: boolean;
}; };
const StyledSelectContainer = styled.div`
margin-left: auto;
`;
export const SettingsOptionCardContentSelect = <Value extends SelectValue>({ export const SettingsOptionCardContentSelect = <Value extends SelectValue>({
Icon, Icon,
title, title,
@ -60,16 +57,18 @@ export const SettingsOptionCardContentSelect = <Value extends SelectValue>({
{description} {description}
</StyledSettingsOptionCardDescription> </StyledSettingsOptionCardDescription>
</div> </div>
<StyledSettingsOptionCardSelect <StyledSelectContainer>
className={selectClassName} <Select<Value>
dropdownWidth={fullWidth ? 'auto' : 120} className={selectClassName}
disabled={disabled} dropdownWidth={fullWidth ? 'auto' : 120}
dropdownId={dropdownId} disabled={disabled}
value={value} dropdownId={dropdownId}
onChange={onChange} value={value}
options={options} onChange={onChange}
selectSizeVariant="small" options={options}
/> selectSizeVariant="small"
/>
</StyledSelectContainer>
</StyledSettingsOptionCardContent> </StyledSettingsOptionCardContent>
); );
}; };

View File

@ -91,7 +91,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
exampleValue: { exampleValue: {
primaryPhoneNumber: '234-567-890', primaryPhoneNumber: '234-567-890',
primaryPhoneCountryCode: '+1', primaryPhoneCountryCode: '+1',
additionalPhones: [{ number: '234-567-890', countryCode: '+1' }], additionalPhones: [{ number: '234-567-890', callingCode: '+1' }],
}, },
subFields: [ subFields: [
'primaryPhoneNumber', 'primaryPhoneNumber',
@ -151,7 +151,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
}, },
exampleValue: { exampleValue: {
addressStreet1: '456 Oak Street', addressStreet1: '456 Oak Street',
addressStreet2: 'Unit 3B', addressStreet2: '',
addressCity: 'Springfield', addressCity: 'Springfield',
addressState: 'California', addressState: 'California',
addressCountry: 'United States', addressCountry: 'United States',

View File

@ -0,0 +1,86 @@
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 { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { IconMap } from 'twenty-ui';
import { z } from 'zod';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type SettingsDataModelFieldAddressFormProps = {
disabled?: boolean;
defaultCountry?: string;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
};
export const settingsDataModelFieldAddressFormSchema = z.object({
defaultValue: addressFieldDefaultValueSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
typeof settingsDataModelFieldAddressFormSchema
>;
export const SettingsDataModelFieldAddressForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldAddressFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
const countries = useCountries()
.sort((a, b) => a.countryName.localeCompare(b.countryName))
.map((country) => ({
label: country.countryName,
value: country.countryName,
}));
countries.unshift({
label: 'No country',
value: '',
});
const defaultDefaultValue = {
addressStreet1: "''",
addressStreet2: null,
addressCity: null,
addressState: null,
addressPostcode: null,
addressCountry: null,
addressLat: null,
addressLng: null,
};
return (
<Controller
name="defaultValue"
defaultValue={{
...defaultDefaultValue,
...fieldMetadataItem?.defaultValue,
}}
control={control}
render={({ field: { onChange, value } }) => {
const defaultCountry = value?.addressCountry || '';
return (
<SettingsOptionCardContentSelect<string>
Icon={IconMap}
dropdownId="selectDefaultCountry"
title="Default Country"
description="The default country for new addresses"
value={stripSimpleQuotesFromString(defaultCountry)}
onChange={(newCountry) =>
onChange({
...value,
addressCountry: applySimpleQuotesToString(newCountry),
})
}
disabled={disabled}
options={countries}
fullWidth={true}
/>
);
}}
/>
);
};

View File

@ -0,0 +1,45 @@
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 {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
type SettingsDataModelFieldAddressSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
export const SettingsDataModelFieldAddressSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldAddressSettingsFormCardProps) => {
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldAddressForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};

View File

@ -5,6 +5,8 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard'; import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs'; import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
import { settingsDataModelFieldAddressFormSchema } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressForm';
import { SettingsDataModelFieldAddressSettingsFormCard } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard';
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm'; import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard'; import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
import { settingsDataModelFieldtextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm'; import { settingsDataModelFieldtextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
@ -15,6 +17,8 @@ import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fiel
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard'; import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm'; import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard'; import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard';
import { settingsDataModelFieldPhonesFormSchema } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import { SettingsDataModelFieldPhonesSettingsFormCard } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard';
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm'; import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard'; import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
import { import {
@ -64,6 +68,14 @@ const textFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Text) }) .object({ type: z.literal(FieldMetadataType.Text) })
.merge(settingsDataModelFieldtextFormSchema); .merge(settingsDataModelFieldtextFormSchema);
const addressFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Address) })
.merge(settingsDataModelFieldAddressFormSchema);
const phonesFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Phones) })
.merge(settingsDataModelFieldPhonesFormSchema);
const otherFieldsFormSchema = z.object({ const otherFieldsFormSchema = z.object({
type: z.enum( type: z.enum(
Object.keys( Object.keys(
@ -76,6 +88,8 @@ const otherFieldsFormSchema = z.object({
FieldMetadataType.Date, FieldMetadataType.Date,
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Address,
FieldMetadataType.Phones,
FieldMetadataType.Text, FieldMetadataType.Text,
]), ]),
) as [FieldMetadataType, ...FieldMetadataType[]], ) as [FieldMetadataType, ...FieldMetadataType[]],
@ -94,6 +108,8 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
multiSelectFieldFormSchema, multiSelectFieldFormSchema,
numberFieldFormSchema, numberFieldFormSchema,
textFieldFormSchema, textFieldFormSchema,
addressFieldFormSchema,
phonesFieldFormSchema,
otherFieldsFormSchema, otherFieldsFormSchema,
], ],
); );
@ -195,6 +211,24 @@ export const SettingsDataModelFieldSettingsFormCard = ({
); );
} }
if (fieldMetadataItem.type === FieldMetadataType.Address) {
return (
<SettingsDataModelFieldAddressSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
);
}
if (fieldMetadataItem.type === FieldMetadataType.Phones) {
return (
<SettingsDataModelFieldPhonesSettingsFormCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
);
}
if ( if (
fieldMetadataItem.type === FieldMetadataType.Select || fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect fieldMetadataItem.type === FieldMetadataType.MultiSelect

View File

@ -0,0 +1,80 @@
import { Controller, useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { IconMap } from 'twenty-ui';
import { z } from 'zod';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type SettingsDataModelFieldPhonesFormProps = {
disabled?: boolean;
defaultCountryCode?: string;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
>;
};
export const settingsDataModelFieldPhonesFormSchema = z.object({
defaultValue: phonesFieldDefaultValueSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
typeof settingsDataModelFieldPhonesFormSchema
>;
export const SettingsDataModelFieldPhonesForm = ({
disabled,
fieldMetadataItem,
}: SettingsDataModelFieldPhonesFormProps) => {
const { control } = useFormContext<SettingsDataModelFieldTextFormValues>();
const countries = useCountries()
.sort((a, b) => a.countryName.localeCompare(b.countryName))
.map((country) => ({
label: `${country.countryName} (+${country.callingCode})`,
value: `${country.callingCode}`,
}));
countries.unshift({ label: 'No country', value: '' });
const defaultDefaultValue = {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
additionalPhones: null,
};
const fieldMetadataItemDefaultValue = fieldMetadataItem?.defaultValue;
return (
<Controller
name="defaultValue"
defaultValue={{
...defaultDefaultValue,
...fieldMetadataItemDefaultValue,
}}
control={control}
render={({ field: { onChange, value } }) => {
return (
<SettingsOptionCardContentSelect<string>
Icon={IconMap}
dropdownId="selectDefaultCountryCode"
title="Default Country Code"
description="The default country code for new phone numbers."
value={stripSimpleQuotesFromString(value?.primaryPhoneCountryCode)}
onChange={(newPhoneCountryCode) =>
onChange({
...value,
primaryPhoneCountryCode:
applySimpleQuotesToString(newPhoneCountryCode),
})
}
disabled={disabled}
options={countries}
fullWidth={true}
/>
);
}}
/>
);
};

View File

@ -0,0 +1,46 @@
import styled from '@emotion/styled';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldPhonesForm } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import {
SettingsDataModelFieldPreviewCard,
SettingsDataModelFieldPreviewCardProps,
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
type SettingsDataModelFieldPhonesSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
export const SettingsDataModelFieldPhonesSettingsFormCard = ({
disabled,
fieldMetadataItem,
objectMetadataItem,
}: SettingsDataModelFieldPhonesSettingsFormCardProps) => {
return (
<SettingsDataModelPreviewFormCard
preview={
<StyledFieldPreviewCard
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldPhonesForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};

View File

@ -2,9 +2,11 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRelationFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useRelationFieldPreviewValue'; import { useRelationFieldPreviewValue } from '@/settings/data-model/fields/preview/hooks/useRelationFieldPreviewValue';
import { getAddressFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue';
import { getCurrencyFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue'; import { getCurrencyFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue';
import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue'; import { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
import { getMultiSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getMultiSelectFieldPreviewValue'; import { getMultiSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getMultiSelectFieldPreviewValue';
import { getPhonesFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
import { getSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getSelectFieldPreviewValue'; import { getSelectFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getSelectFieldPreviewValue';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -45,6 +47,10 @@ export const useFieldPreviewValue = ({
return getSelectFieldPreviewValue({ fieldMetadataItem }); return getSelectFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.MultiSelect: case FieldMetadataType.MultiSelect:
return getMultiSelectFieldPreviewValue({ fieldMetadataItem }); return getMultiSelectFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.Address:
return getAddressFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.Phones:
return getPhonesFieldPreviewValue({ fieldMetadataItem });
default: default:
return getFieldPreviewValue({ fieldMetadataItem }); return getFieldPreviewValue({ fieldMetadataItem });
} }

View File

@ -0,0 +1,34 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldAddressValue } from '@/object-record/record-field/types/FieldMetadata';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getAddressFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}): FieldAddressValue | null => {
if (fieldMetadataItem.type !== FieldMetadataType.Address) return null;
const addressFieldTypeConfig = getSettingsFieldTypeConfig(
FieldMetadataType.Address,
);
const placeholderDefaultValue = addressFieldTypeConfig.exampleValue;
const addressCountry =
fieldMetadataItem.defaultValue?.addressCountry &&
fieldMetadataItem.defaultValue.addressCountry !== ''
? stripSimpleQuotesFromString(
fieldMetadataItem.defaultValue?.addressCountry,
)
: null;
return {
...placeholderDefaultValue,
addressCountry: addressCountry,
};
};

View File

@ -0,0 +1,33 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const getPhonesFieldPreviewValue = ({
fieldMetadataItem,
}: {
fieldMetadataItem: Pick<
FieldMetadataItem,
'defaultValue' | 'options' | 'type'
>;
}): FieldPhonesValue | null => {
if (fieldMetadataItem.type !== FieldMetadataType.Phones) return null;
const phonesFieldTypeConfig = getSettingsFieldTypeConfig(
FieldMetadataType.Phones,
);
const placeholderDefaultValue = phonesFieldTypeConfig.exampleValue;
const primaryPhoneCountryCode =
fieldMetadataItem.defaultValue?.primaryPhoneCountryCode &&
fieldMetadataItem.defaultValue.primaryPhoneCountryCode !== ''
? `+${stripSimpleQuotesFromString(
fieldMetadataItem.defaultValue?.primaryPhoneCountryCode,
)}`
: null;
return {
...placeholderDefaultValue,
primaryPhoneCountryCode,
};
};

View File

@ -4,28 +4,37 @@ import { ContactLink } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
type PhoneDisplayProps = { interface PhoneDisplayProps {
value: string | null | undefined; value: PhoneDisplayValueProps;
}
type PhoneDisplayValueProps = {
number: string | null | undefined;
callingCode: string | null | undefined;
}; };
// TODO: see if we can find a faster way to format the phone number export const PhoneDisplay = ({
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => { value: { number, callingCode },
if (!isDefined(value)) { }: PhoneDisplayProps) => {
return <ContactLink href="#">{value}</ContactLink>; if (!isDefined(number)) return <ContactLink href="#">{number}</ContactLink>;
}
const callingCodeSanitized = callingCode?.replace('+', '');
let parsedPhoneNumber: PhoneNumber | null = null; let parsedPhoneNumber: PhoneNumber | null = null;
try { try {
// TODO: parse according to locale not hard coded FR parsedPhoneNumber = parsePhoneNumber(number, {
parsedPhoneNumber = parsePhoneNumber(value, 'FR'); defaultCallingCode: callingCodeSanitized || '1',
});
} catch (error) { } catch (error) {
return <ContactLink href="#">{value}</ContactLink>; if (!(error instanceof Error))
return <ContactLink href="#">{number}</ContactLink>;
if (error.message === 'NOT_A_NUMBER')
return <ContactLink href="#">{`+${callingCodeSanitized}`}</ContactLink>;
return <ContactLink href="#">{number}</ContactLink>;
} }
const URI = parsedPhoneNumber.getURI(); const URI = parsedPhoneNumber.getURI();
const formatedPhoneNumber = parsedPhoneNumber.formatInternational(); const formatedPhoneNumber = parsedPhoneNumber.formatInternational();
return ( return (
<ContactLink <ContactLink
href={URI} href={URI}
@ -33,7 +42,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
event.stopPropagation(); event.stopPropagation();
}} }}
> >
{formatedPhoneNumber || value} {formatedPhoneNumber || number}
</ContactLink> </ContactLink>
); );
}; };

View File

@ -36,16 +36,16 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
value?.primaryPhoneNumber value?.primaryPhoneNumber
? { ? {
number: value.primaryPhoneNumber, number: value.primaryPhoneNumber,
countryCode: value.primaryPhoneCountryCode, callingCode: value.primaryPhoneCountryCode,
} }
: null, : null,
...parseAdditionalPhones(value?.additionalPhones), ...parseAdditionalPhones(value?.additionalPhones),
] ]
.filter(isDefined) .filter(isDefined)
.map(({ number, countryCode }) => { .map(({ number, callingCode }) => {
return { return {
number, number,
countryCode, callingCode,
}; };
}), }),
[ [
@ -65,9 +65,9 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
return isFocused ? ( return isFocused ? (
<ExpandableList isChipCountDisplayed> <ExpandableList isChipCountDisplayed>
{phones.map(({ number, countryCode }, index) => { {phones.map(({ number, callingCode }, index) => {
const { parsedPhone, invalidPhone } = const { parsedPhone, invalidPhone } =
parsePhoneNumberOrReturnInvalidValue(countryCode + number); parsePhoneNumberOrReturnInvalidValue(`+${callingCode}` + number);
const URI = parsedPhone?.getURI(); const URI = parsedPhone?.getURI();
return ( return (
<RoundedLink <RoundedLink
@ -82,9 +82,9 @@ export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
</ExpandableList> </ExpandableList>
) : ( ) : (
<StyledContainer> <StyledContainer>
{phones.map(({ number, countryCode }, index) => { {phones.map(({ number, callingCode }, index) => {
const { parsedPhone, invalidPhone } = const { parsedPhone, invalidPhone } =
parsePhoneNumberOrReturnInvalidValue(countryCode + number); parsePhoneNumberOrReturnInvalidValue(`+${callingCode}` + number);
const URI = parsedPhone?.getURI(); const URI = parsedPhone?.getURI();
return ( return (
<RoundedLink <RoundedLink

View File

@ -26,7 +26,9 @@ type CallToActionButton = {
Icon?: IconComponent; Icon?: IconComponent;
}; };
export type SelectProps<Value extends string | number | boolean | null> = { export type SelectValue = string | number | boolean | null;
export type SelectProps<Value extends SelectValue> = {
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
selectSizeVariant?: SelectSizeVariant; selectSizeVariant?: SelectSizeVariant;
@ -57,7 +59,7 @@ const StyledLabel = styled.span`
margin-bottom: ${({ theme }) => theme.spacing(1)}; margin-bottom: ${({ theme }) => theme.spacing(1)};
`; `;
export const Select = <Value extends string | number | boolean | null>({ export const Select = <Value extends SelectValue>({
className, className,
disabled: disabledFromProps, disabled: disabledFromProps,
selectSizeVariant, selectSizeVariant,

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { IconComponentProps } from 'twenty-ui'; import { IconCircleOff, IconComponentProps } from 'twenty-ui';
import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId'; import { SELECT_COUNTRY_DROPDOWN_ID } from '@/ui/input/components/internal/country/constants/SelectCountryDropdownId';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
@ -15,12 +15,20 @@ export const CountrySelect = ({
const countries = useCountries(); const countries = useCountries();
const options: SelectOption<string>[] = useMemo(() => { const options: SelectOption<string>[] = useMemo(() => {
return countries.map<SelectOption<string>>(({ countryName, Flag }) => ({ const countryList = countries.map<SelectOption<string>>(
label: countryName, ({ countryName, Flag }) => ({
value: countryName, label: countryName,
Icon: (props: IconComponentProps) => value: countryName,
Flag({ width: props.size, height: props.size }), // TODO : improve this ? Icon: (props: IconComponentProps) =>
})); Flag({ width: props.size, height: props.size }), // TODO : improve this ?
}),
);
countryList.unshift({
label: 'No country',
value: '',
Icon: IconCircleOff,
});
return countryList;
}, [countries]); }, [countries]);
return ( return (

View File

@ -1,8 +1,8 @@
import * as Flags from 'country-flag-icons/react/3x2'; import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js'; import { CountryCallingCode, CountryCode } from 'libphonenumber-js';
export type Country = { export type Country = {
countryCode: string; countryCode: CountryCode;
countryName: string; countryName: string;
callingCode: CountryCallingCode; callingCode: CountryCallingCode;
Flag: Flags.FlagComponent; Flag: Flags.FlagComponent;

View File

@ -6,3 +6,20 @@ export const stripSimpleQuotesFromString = <Input extends string>(
(simpleQuotesStringSchema.safeParse(value).success (simpleQuotesStringSchema.safeParse(value).success
? value.slice(1, -1) ? value.slice(1, -1)
: value) as Input extends `'${infer Output}'` ? Output : Input; : value) as Input extends `'${infer Output}'` ? Output : Input;
export const stripSimpleQuotesFromStringRecursive = (obj: any): any => {
if (typeof obj === 'string') {
return stripSimpleQuotesFromString(obj);
} else if (Array.isArray(obj)) {
return obj.map(stripSimpleQuotesFromStringRecursive);
} else if (typeof obj === 'object' && obj !== null) {
const newObj: any = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) === true) {
newObj[key] = stripSimpleQuotesFromStringRecursive(obj[key]);
}
}
return newObj;
}
return obj;
};

View File

@ -23,7 +23,7 @@ enum ValueType {
NUMBER = 'number', NUMBER = 'number',
} }
class SettingsValidation { class NumberSettingsValidation {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0) @Min(0)
@ -32,7 +32,9 @@ class SettingsValidation {
@IsOptional() @IsOptional()
@IsEnum(ValueType) @IsEnum(ValueType)
type?: 'percentage' | 'number'; type?: 'percentage' | 'number';
}
class TextSettingsValidation {
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0) @Min(0)
@ -55,17 +57,19 @@ export class FieldMetadataValidationService<
}) { }) {
switch (fieldType) { switch (fieldType) {
case FieldMetadataType.NUMBER: case FieldMetadataType.NUMBER:
await this.validateSettings(NumberSettingsValidation, settings);
break;
case FieldMetadataType.TEXT: case FieldMetadataType.TEXT:
await this.validateSettings(settings); await this.validateSettings(TextSettingsValidation, settings);
break; break;
default: default:
break; break;
} }
} }
private async validateSettings(settings: any) { private async validateSettings(validator: any, settings: any) {
try { try {
const settingsInstance = plainToInstance(SettingsValidation, settings); const settingsInstance = plainToInstance(validator, settings);
await validateOrReject(settingsInstance); await validateOrReject(settingsInstance);
} catch (error) { } catch (error) {

View File

@ -0,0 +1,59 @@
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
describe('FieldMetadataValidationService', () => {
let service: FieldMetadataValidationService;
beforeAll(() => {
service = new FieldMetadataValidationService();
});
it('should validate NUMBER settings successfully', async () => {
const settings = { decimals: 2, type: 'number' } as FieldMetadataSettings;
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.NUMBER,
settings,
}),
).resolves.not.toThrow();
});
it('should throw an error for invalid NUMBER settings', async () => {
const settings = { type: 'invalidType' } as FieldMetadataSettings;
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.NUMBER,
settings,
}),
).rejects.toThrow(FieldMetadataException);
});
it('should validate TEXT settings successfully', async () => {
const settings = { displayedMaxRows: 10 } as FieldMetadataSettings;
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.TEXT,
settings,
}),
).resolves.not.toThrow();
});
it('should throw an error for invalid TEXT settings', async () => {
const settings = {
displayedMaxRows: 'NotANumber',
} as FieldMetadataSettings;
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.TEXT,
settings,
}),
).rejects.toThrow(FieldMetadataException);
});
});

View File

@ -172,7 +172,7 @@ const get_subfieldsFromField = (nodeField: NodeField): NodeField[] => {
description: 'Additional Phones', description: 'Additional Phones',
isNullable: true, isNullable: true,
defaultValue: null, defaultValue: null,
placeholder: '{ number: "", countryCode: "" }', placeholder: '{ number: "", callingCode: "" }',
list: true, list: true,
}; };
return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones]; return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones];