Import - Improve phone validation (#12901)
Context : - Phones import is a bit complex if not all subfields are provided. - Phones subfield validation are absent or different from BE validation. Solution : - Normalize callingCode and countryCode validation (BE/FE) - Ease phone import if only phoneNumber is provided
This commit is contained in:
@ -405,4 +405,39 @@ describe('buildRecordFromImportedStructuredRow', () => {
|
||||
ratingField: '4',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case where user provides only a primaryPhoneNumber without calling code', () => {
|
||||
const importedStructuredRow: ImportedStructuredRow<string> = {
|
||||
'Primary Phone Number (phoneField)': '5550123',
|
||||
};
|
||||
|
||||
const fields: FieldMetadataItem[] = [
|
||||
{
|
||||
id: '13',
|
||||
name: 'phoneField',
|
||||
label: 'Phone Field',
|
||||
type: FieldMetadataType.PHONES,
|
||||
isNullable: true,
|
||||
isActive: true,
|
||||
isCustom: false,
|
||||
isSystem: false,
|
||||
createdAt: '2023-01-01',
|
||||
updatedAt: '2023-01-01',
|
||||
icon: 'IconPhone',
|
||||
description: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildRecordFromImportedStructuredRow({
|
||||
importedStructuredRow,
|
||||
fields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
phoneField: {
|
||||
primaryPhoneNumber: '5550123',
|
||||
primaryPhoneCallingCode: '+1',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
|
||||
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
|
||||
import { ImportedStructuredRow } from '@/spreadsheet-import/types';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { castToString } from '~/utils/castToString';
|
||||
import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros';
|
||||
import { isEmptyObject } from '~/utils/isEmptyObject';
|
||||
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
||||
|
||||
type BuildRecordFromImportedStructuredRowArgs = {
|
||||
importedStructuredRow: ImportedStructuredRow<any>;
|
||||
@ -16,23 +18,14 @@ type BuildRecordFromImportedStructuredRowArgs = {
|
||||
};
|
||||
|
||||
const buildCompositeFieldRecord = (
|
||||
fieldName: string,
|
||||
field: FieldMetadataItem,
|
||||
importedStructuredRow: ImportedStructuredRow<any>,
|
||||
compositeFieldConfig: Record<
|
||||
string,
|
||||
{
|
||||
labelKey: string;
|
||||
transform?: (value: any) => any;
|
||||
}
|
||||
>,
|
||||
compositeFieldConfig: Record<string, ((value: any) => any) | undefined>,
|
||||
): Record<string, any> | undefined => {
|
||||
const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce(
|
||||
(
|
||||
acc,
|
||||
[compositeFieldKey, { labelKey: compositeFieldLabelKey, transform }],
|
||||
) => {
|
||||
(acc, [compositeFieldKey, transform]) => {
|
||||
const value =
|
||||
importedStructuredRow[`${compositeFieldLabelKey} (${fieldName})`];
|
||||
importedStructuredRow[getSubFieldOptionKey(field, compositeFieldKey)];
|
||||
|
||||
return isDefined(value)
|
||||
? { ...acc, [compositeFieldKey]: transform?.(value) || value }
|
||||
@ -106,123 +99,49 @@ export const buildRecordFromImportedStructuredRow = ({
|
||||
|
||||
const recordToBuild: Record<string, any> = {};
|
||||
|
||||
const {
|
||||
ADDRESS: {
|
||||
addressCity: addressCityLabel,
|
||||
addressCountry: addressCountryLabel,
|
||||
addressPostcode: addressPostcodeLabel,
|
||||
addressState: addressStateLabel,
|
||||
addressStreet1: addressStreet1Label,
|
||||
addressStreet2: addressStreet2Label,
|
||||
},
|
||||
CURRENCY: {
|
||||
amountMicros: amountMicrosLabel,
|
||||
currencyCode: currencyCodeLabel,
|
||||
},
|
||||
FULL_NAME: { firstName: firstNameLabel, lastName: lastNameLabel },
|
||||
LINKS: {
|
||||
primaryLinkUrl: primaryLinkUrlLabel,
|
||||
primaryLinkLabel: primaryLinkLabelLabel,
|
||||
secondaryLinks: secondaryLinksLabel,
|
||||
},
|
||||
EMAILS: {
|
||||
primaryEmail: primaryEmailLabel,
|
||||
additionalEmails: additionalEmailsLabel,
|
||||
},
|
||||
PHONES: {
|
||||
primaryPhoneNumber: primaryPhoneNumberLabel,
|
||||
primaryPhoneCountryCode: primaryPhoneCountryCodeLabel,
|
||||
primaryPhoneCallingCode: primaryPhoneCallingCodeLabel,
|
||||
additionalPhones: additionalPhonesLabel,
|
||||
},
|
||||
RICH_TEXT_V2: { blocknote: blocknoteLabel, markdown: markdownLabel },
|
||||
} = COMPOSITE_FIELD_SUB_FIELD_LABELS;
|
||||
|
||||
const COMPOSITE_FIELD_CONFIGS = {
|
||||
const COMPOSITE_FIELD_TRANSFORM_CONFIGS = {
|
||||
[FieldMetadataType.CURRENCY]: {
|
||||
amountMicros: {
|
||||
labelKey: amountMicrosLabel,
|
||||
transform: (value: any) =>
|
||||
convertCurrencyAmountToCurrencyMicros(Number(value)),
|
||||
},
|
||||
currencyCode: { labelKey: currencyCodeLabel },
|
||||
amountMicros: (value: any) =>
|
||||
convertCurrencyAmountToCurrencyMicros(Number(value)),
|
||||
currencyCode: undefined,
|
||||
},
|
||||
|
||||
[FieldMetadataType.ADDRESS]: {
|
||||
addressStreet1: {
|
||||
labelKey: addressStreet1Label,
|
||||
transform: castToString,
|
||||
},
|
||||
addressStreet2: {
|
||||
labelKey: addressStreet2Label,
|
||||
transform: castToString,
|
||||
},
|
||||
addressCity: { labelKey: addressCityLabel, transform: castToString },
|
||||
addressPostcode: {
|
||||
labelKey: addressPostcodeLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
addressState: { labelKey: addressStateLabel, transform: castToString },
|
||||
addressCountry: {
|
||||
labelKey: addressCountryLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
addressStreet1: castToString,
|
||||
addressStreet2: castToString,
|
||||
addressCity: castToString,
|
||||
addressPostcode: castToString,
|
||||
addressState: castToString,
|
||||
addressCountry: castToString,
|
||||
},
|
||||
|
||||
[FieldMetadataType.LINKS]: {
|
||||
primaryLinkLabel: {
|
||||
labelKey: primaryLinkLabelLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
primaryLinkUrl: {
|
||||
labelKey: primaryLinkUrlLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
secondaryLinks: {
|
||||
labelKey: secondaryLinksLabel,
|
||||
transform: linkArrayJSONSchema.parse,
|
||||
},
|
||||
primaryLinkLabel: castToString,
|
||||
primaryLinkUrl: castToString,
|
||||
secondaryLinks: linkArrayJSONSchema.parse,
|
||||
},
|
||||
|
||||
[FieldMetadataType.PHONES]: {
|
||||
primaryPhoneCountryCode: {
|
||||
labelKey: primaryPhoneCountryCodeLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
primaryPhoneNumber: {
|
||||
labelKey: primaryPhoneNumberLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
primaryPhoneCallingCode: {
|
||||
labelKey: primaryPhoneCallingCodeLabel,
|
||||
transform: castToString,
|
||||
},
|
||||
additionalPhones: {
|
||||
labelKey: additionalPhonesLabel,
|
||||
transform: phoneArrayJSONSchema.parse,
|
||||
},
|
||||
primaryPhoneCountryCode: castToString,
|
||||
primaryPhoneNumber: castToString,
|
||||
primaryPhoneCallingCode: castToString,
|
||||
additionalPhones: phoneArrayJSONSchema.parse,
|
||||
},
|
||||
|
||||
[FieldMetadataType.RICH_TEXT_V2]: {
|
||||
blocknote: { labelKey: blocknoteLabel, transform: castToString },
|
||||
markdown: { labelKey: markdownLabel, transform: castToString },
|
||||
blocknote: castToString,
|
||||
markdown: castToString,
|
||||
},
|
||||
|
||||
[FieldMetadataType.EMAILS]: {
|
||||
primaryEmail: { labelKey: primaryEmailLabel, transform: castToString },
|
||||
additionalEmails: {
|
||||
labelKey: additionalEmailsLabel,
|
||||
transform: stringArrayJSONSchema.parse,
|
||||
},
|
||||
primaryEmail: castToString,
|
||||
additionalEmails: stringArrayJSONSchema.parse,
|
||||
},
|
||||
|
||||
[FieldMetadataType.FULL_NAME]: {
|
||||
firstName: { labelKey: firstNameLabel },
|
||||
lastName: { labelKey: lastNameLabel },
|
||||
firstName: undefined,
|
||||
lastName: undefined,
|
||||
},
|
||||
[FieldMetadataType.ACTOR]: {
|
||||
source: { labelKey: 'source', transform: () => 'IMPORT' },
|
||||
context: { labelKey: 'context', transform: () => ({}) },
|
||||
source: () => 'IMPORT',
|
||||
context: () => ({}),
|
||||
},
|
||||
};
|
||||
|
||||
@ -233,20 +152,83 @@ export const buildRecordFromImportedStructuredRow = ({
|
||||
case FieldMetadataType.CURRENCY:
|
||||
case FieldMetadataType.ADDRESS:
|
||||
case FieldMetadataType.LINKS:
|
||||
case FieldMetadataType.PHONES:
|
||||
case FieldMetadataType.RICH_TEXT_V2:
|
||||
case FieldMetadataType.EMAILS:
|
||||
case FieldMetadataType.FULL_NAME: {
|
||||
const compositeData = buildCompositeFieldRecord(
|
||||
field.name,
|
||||
field,
|
||||
importedStructuredRow,
|
||||
COMPOSITE_FIELD_CONFIGS[field.type],
|
||||
COMPOSITE_FIELD_TRANSFORM_CONFIGS[field.type],
|
||||
);
|
||||
if (isDefined(compositeData)) {
|
||||
recordToBuild[field.name] = compositeData;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FieldMetadataType.PHONES: {
|
||||
const compositeData = buildCompositeFieldRecord(
|
||||
field,
|
||||
importedStructuredRow,
|
||||
COMPOSITE_FIELD_TRANSFORM_CONFIGS[field.type],
|
||||
);
|
||||
if (!isDefined(compositeData)) {
|
||||
break;
|
||||
}
|
||||
recordToBuild[field.name] = compositeData;
|
||||
|
||||
const primaryPhoneNumber =
|
||||
importedStructuredRow[
|
||||
getSubFieldOptionKey(field, 'primaryPhoneNumber')
|
||||
];
|
||||
|
||||
const primaryPhoneCallingCode =
|
||||
importedStructuredRow[
|
||||
getSubFieldOptionKey(field, 'primaryPhoneCallingCode')
|
||||
];
|
||||
|
||||
const hasUserProvidedPrimaryPhoneNumberWithoutCallingCode =
|
||||
isDefined(primaryPhoneNumber) &&
|
||||
(!isDefined(primaryPhoneCallingCode) ||
|
||||
!isNonEmptyString(primaryPhoneCallingCode));
|
||||
|
||||
// To meet backend requirements, handle case where user provides only a primaryPhoneNumber without calling code
|
||||
if (hasUserProvidedPrimaryPhoneNumberWithoutCallingCode) {
|
||||
const primaryPhoneCountryCode =
|
||||
importedStructuredRow[
|
||||
getSubFieldOptionKey(field, 'primaryPhoneCountryCode')
|
||||
];
|
||||
|
||||
const hasUserProvidedPrimaryPhoneCountryCode =
|
||||
isDefined(primaryPhoneCountryCode) &&
|
||||
isNonEmptyString(primaryPhoneCountryCode);
|
||||
|
||||
try {
|
||||
const {
|
||||
number: parsedNumber,
|
||||
countryCallingCode: parsedCountryCallingCode,
|
||||
} = parsePhoneNumberWithError(
|
||||
primaryPhoneNumber as string,
|
||||
hasUserProvidedPrimaryPhoneCountryCode
|
||||
? (primaryPhoneCountryCode as CountryCode)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
recordToBuild[field.name] = {
|
||||
primaryPhoneNumber: parsedNumber,
|
||||
primaryPhoneCallingCode: `+${parsedCountryCallingCode}`,
|
||||
};
|
||||
} catch {
|
||||
recordToBuild[field.name] = {
|
||||
primaryPhoneNumber,
|
||||
primaryPhoneCallingCode:
|
||||
stripSimpleQuotesFromString(
|
||||
field?.defaultValue?.primaryPhoneCallingCode,
|
||||
) || '+1',
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FieldMetadataType.BOOLEAN:
|
||||
recordToBuild[field.name] =
|
||||
importedFieldValue === 'true' || importedFieldValue === true;
|
||||
|
||||
@ -4,7 +4,14 @@ import { emailSchema } from '@/object-record/record-field/validation-schemas/ema
|
||||
import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDate, isString } from '@sniptt/guards';
|
||||
import { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { parsePhoneNumberWithError } from 'libphonenumber-js';
|
||||
import {
|
||||
absoluteUrlSchema,
|
||||
getCountryCodesForCallingCode,
|
||||
isDefined,
|
||||
isValidCountryCode,
|
||||
isValidUuid,
|
||||
} from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const getNumberValidationDefinition = (
|
||||
@ -16,6 +23,20 @@ const getNumberValidationDefinition = (
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
const isValidPhoneNumber = (value: string) => {
|
||||
try {
|
||||
return isDefined(
|
||||
parsePhoneNumberWithError(value, { defaultCallingCode: '1' }),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isValidCallingCode = (value: string) => {
|
||||
return getCountryCodesForCallingCode(value).length > 0;
|
||||
};
|
||||
|
||||
export const getSpreadSheetFieldValidationDefinitions = (
|
||||
type: FieldMetadataType,
|
||||
fieldName: string,
|
||||
@ -143,9 +164,27 @@ export const getSpreadSheetFieldValidationDefinitions = (
|
||||
case 'primaryPhoneNumber':
|
||||
return [
|
||||
{
|
||||
rule: 'regex',
|
||||
value: '^[0-9]+$',
|
||||
errorMessage: `${fieldName} ${t`must contain only numbers`}`,
|
||||
rule: 'function',
|
||||
isValid: isValidPhoneNumber,
|
||||
errorMessage: `${fieldName} ${t`is not a valid phone number`}`,
|
||||
level: 'error',
|
||||
},
|
||||
];
|
||||
case 'primaryPhoneCallingCode':
|
||||
return [
|
||||
{
|
||||
rule: 'function',
|
||||
isValid: isValidCallingCode,
|
||||
errorMessage: `${fieldName} ${t`is not a valid calling code`}`,
|
||||
level: 'error',
|
||||
},
|
||||
];
|
||||
case 'primaryPhoneCountryCode':
|
||||
return [
|
||||
{
|
||||
rule: 'function',
|
||||
isValid: isValidCountryCode,
|
||||
errorMessage: `${fieldName} ${t`is not a valid country code`}`,
|
||||
level: 'error',
|
||||
},
|
||||
];
|
||||
@ -165,10 +204,9 @@ export const getSpreadSheetFieldValidationDefinitions = (
|
||||
callingCode: string;
|
||||
countryCode: string;
|
||||
}) =>
|
||||
isDefined(phone.number) &&
|
||||
/^[0-9]+$/.test(phone.number) &&
|
||||
isDefined(phone.callingCode) &&
|
||||
isDefined(phone.countryCode),
|
||||
isValidPhoneNumber(phone.number) &&
|
||||
isValidCallingCode(phone.callingCode) &&
|
||||
isValidCountryCode(phone.countryCode),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@ -2,13 +2,12 @@ import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
CountryCallingCode,
|
||||
CountryCode,
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
parsePhoneNumberWithError,
|
||||
} from 'libphonenumber-js';
|
||||
import {
|
||||
getCountryCodesForCallingCode,
|
||||
isDefined,
|
||||
isValidCountryCode,
|
||||
parseJson,
|
||||
removeUndefinedFields,
|
||||
} from 'twenty-shared/utils';
|
||||
@ -22,8 +21,6 @@ import {
|
||||
PhonesMetadata,
|
||||
} from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
|
||||
|
||||
const ALL_COUNTRIES_CODE = getCountries();
|
||||
|
||||
export type PhonesFieldGraphQLInput =
|
||||
| Partial<
|
||||
Omit<PhonesMetadata, 'additionalPhones'> & {
|
||||
@ -38,22 +35,6 @@ type AdditionalPhoneMetadataWithNumber = Partial<AdditionalPhoneMetadata> &
|
||||
|
||||
const removePlusFromString = (str: string) => str.replace(/\+/g, '');
|
||||
|
||||
const isValidCountryCode = (input: string): input is CountryCode => {
|
||||
return ALL_COUNTRIES_CODE.includes(input as unknown as CountryCode);
|
||||
};
|
||||
|
||||
const getCountryCodesForCallingCode = (callingCode: string) => {
|
||||
const cleanCallingCode = callingCode.startsWith('+')
|
||||
? callingCode.slice(1)
|
||||
: callingCode;
|
||||
|
||||
return ALL_COUNTRIES_CODE.filter((country) => {
|
||||
const countryCallingCode = getCountryCallingCode(country);
|
||||
|
||||
return countryCallingCode === cleanCallingCode;
|
||||
});
|
||||
};
|
||||
|
||||
const validatePrimaryPhoneCountryCodeAndCallingCode = ({
|
||||
callingCode,
|
||||
countryCode,
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"libphonenumber-js": "^1.10.26",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"preconstruct": {
|
||||
|
||||
@ -33,3 +33,5 @@ export { isValidLocale } from './validation/isValidLocale';
|
||||
export { isValidUuid } from './validation/isValidUuid';
|
||||
export { isValidVariable } from './validation/isValidVariable';
|
||||
export { normalizeLocale } from './validation/normalizeLocale';
|
||||
export { getCountryCodesForCallingCode } from './validation/phones-value/getCountryCodesForCallingCode';
|
||||
export { isValidCountryCode } from './validation/phones-value/isValidCountryCode';
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import { getCountries, getCountryCallingCode } from 'libphonenumber-js';
|
||||
|
||||
const ALL_COUNTRIES_CODE = getCountries();
|
||||
|
||||
export const getCountryCodesForCallingCode = (callingCode: string) => {
|
||||
const cleanCallingCode = callingCode.startsWith('+')
|
||||
? callingCode.slice(1)
|
||||
: callingCode;
|
||||
|
||||
return ALL_COUNTRIES_CODE.filter((country) => {
|
||||
const countryCallingCode = getCountryCallingCode(country);
|
||||
|
||||
return countryCallingCode === cleanCallingCode;
|
||||
});
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export * from './isValidCountryCode';
|
||||
@ -0,0 +1,7 @@
|
||||
import { CountryCode, getCountries } from 'libphonenumber-js';
|
||||
|
||||
const ALL_COUNTRIES_CODE = getCountries();
|
||||
|
||||
export const isValidCountryCode = (input: string): input is CountryCode => {
|
||||
return ALL_COUNTRIES_CODE.includes(input as unknown as CountryCode);
|
||||
};
|
||||
Reference in New Issue
Block a user