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 = [
FieldMetadataType.Address,
FieldMetadataType.Phones,
FieldMetadataType.Links,
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
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 { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
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 { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
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 { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
type computeDraftValueFromFieldValueParams<FieldValue> = {
fieldDefinition: Pick<FieldDefinition<FieldMetadata>, 'type' | 'metadata'>;
@ -42,6 +45,38 @@ export const computeDraftValueFromFieldValue = <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 (
isFieldNumber(fieldDefinition) &&
isFieldNumberValue(fieldValue) &&

View File

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

View File

@ -91,7 +91,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
exampleValue: {
primaryPhoneNumber: '234-567-890',
primaryPhoneCountryCode: '+1',
additionalPhones: [{ number: '234-567-890', countryCode: '+1' }],
additionalPhones: [{ number: '234-567-890', callingCode: '+1' }],
},
subFields: [
'primaryPhoneNumber',
@ -151,7 +151,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
},
exampleValue: {
addressStreet1: '456 Oak Street',
addressStreet2: 'Unit 3B',
addressStreet2: '',
addressCity: 'Springfield',
addressState: 'California',
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 { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
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 { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
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 { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
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 { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
import {
@ -64,6 +68,14 @@ const textFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.Text) })
.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({
type: z.enum(
Object.keys(
@ -76,6 +88,8 @@ const otherFieldsFormSchema = z.object({
FieldMetadataType.Date,
FieldMetadataType.DateTime,
FieldMetadataType.Number,
FieldMetadataType.Address,
FieldMetadataType.Phones,
FieldMetadataType.Text,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
@ -94,6 +108,8 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
multiSelectFieldFormSchema,
numberFieldFormSchema,
textFieldFormSchema,
addressFieldFormSchema,
phonesFieldFormSchema,
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 (
fieldMetadataItem.type === FieldMetadataType.Select ||
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 { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
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 { getFieldPreviewValue } from '@/settings/data-model/fields/preview/utils/getFieldPreviewValue';
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 { FieldMetadataType } from '~/generated-metadata/graphql';
@ -45,6 +47,10 @@ export const useFieldPreviewValue = ({
return getSelectFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.MultiSelect:
return getMultiSelectFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.Address:
return getAddressFieldPreviewValue({ fieldMetadataItem });
case FieldMetadataType.Phones:
return getPhonesFieldPreviewValue({ fieldMetadataItem });
default:
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';
type PhoneDisplayProps = {
value: string | null | undefined;
interface PhoneDisplayProps {
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 = ({ value }: PhoneDisplayProps) => {
if (!isDefined(value)) {
return <ContactLink href="#">{value}</ContactLink>;
}
export const PhoneDisplay = ({
value: { number, callingCode },
}: PhoneDisplayProps) => {
if (!isDefined(number)) return <ContactLink href="#">{number}</ContactLink>;
const callingCodeSanitized = callingCode?.replace('+', '');
let parsedPhoneNumber: PhoneNumber | null = null;
try {
// TODO: parse according to locale not hard coded FR
parsedPhoneNumber = parsePhoneNumber(value, 'FR');
parsedPhoneNumber = parsePhoneNumber(number, {
defaultCallingCode: callingCodeSanitized || '1',
});
} 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 formatedPhoneNumber = parsedPhoneNumber.formatInternational();
return (
<ContactLink
href={URI}
@ -33,7 +42,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
event.stopPropagation();
}}
>
{formatedPhoneNumber || value}
{formatedPhoneNumber || number}
</ContactLink>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -6,3 +6,20 @@ export const stripSimpleQuotesFromString = <Input extends string>(
(simpleQuotesStringSchema.safeParse(value).success
? value.slice(1, -1)
: 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',
}
class SettingsValidation {
class NumberSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
@ -32,7 +32,9 @@ class SettingsValidation {
@IsOptional()
@IsEnum(ValueType)
type?: 'percentage' | 'number';
}
class TextSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
@ -55,17 +57,19 @@ export class FieldMetadataValidationService<
}) {
switch (fieldType) {
case FieldMetadataType.NUMBER:
await this.validateSettings(NumberSettingsValidation, settings);
break;
case FieldMetadataType.TEXT:
await this.validateSettings(settings);
await this.validateSettings(TextSettingsValidation, settings);
break;
default:
break;
}
}
private async validateSettings(settings: any) {
private async validateSettings(validator: any, settings: any) {
try {
const settingsInstance = plainToInstance(SettingsValidation, settings);
const settingsInstance = plainToInstance(validator, settings);
await validateOrReject(settingsInstance);
} 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',
isNullable: true,
defaultValue: null,
placeholder: '{ number: "", countryCode: "" }',
placeholder: '{ number: "", callingCode: "" }',
list: true,
};
return [primaryPhoneNumber, primaryPhoneCountryCode, additionalPhones];