add field validation + add other subfields import (#12444)

- Add some subfield imports : primaryLinkLabel / primaryPhoneCallingCode
/ additionalPhones
- Add validation rules for field and subfield

Comments
- Check other validations that can be done
- Refacto on subFieldKey ("...Label")
- Add global tests on validation step -
[issue](https://github.com/twentyhq/core-team-issues/issues/1067)

closes https://github.com/twentyhq/core-team-issues/issues/903 
closes https://github.com/twentyhq/core-team-issues/issues/910
closes https://github.com/twentyhq/core-team-issues/issues/985
closes https://github.com/twentyhq/core-team-issues/issues/904
This commit is contained in:
Etienne
2025-06-05 14:12:24 +02:00
committed by GitHub
parent b481abbb0f
commit 2dd8b9af10
7 changed files with 731 additions and 89 deletions

View File

@ -53,17 +53,20 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
primaryLinkUrlLabel: primaryLinkUrlLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.primaryLinkUrl, .primaryLinkUrl,
primaryLinkLabelLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.primaryLinkLabel,
secondaryLinksLabel: secondaryLinksLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField
.secondaryLinks, .secondaryLinks,
} satisfies Partial<CompositeFieldLabels<FieldLinksValue>>, } satisfies CompositeFieldLabels<FieldLinksValue>,
[FieldMetadataType.EMAILS]: { [FieldMetadataType.EMAILS]: {
primaryEmailLabel: primaryEmailLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail, SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail,
additionalEmailsLabel: additionalEmailsLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField
.additionalEmails, .additionalEmails,
} satisfies Partial<CompositeFieldLabels<FieldEmailsValue>>, } satisfies CompositeFieldLabels<FieldEmailsValue>,
[FieldMetadataType.PHONES]: { [FieldMetadataType.PHONES]: {
primaryPhoneCountryCodeLabel: primaryPhoneCountryCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
@ -71,7 +74,13 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
primaryPhoneNumberLabel: primaryPhoneNumberLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneNumber, .primaryPhoneNumber,
} satisfies Partial<CompositeFieldLabels<FieldPhonesValue>>, primaryPhoneCallingCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.primaryPhoneCallingCode,
additionalPhonesLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField
.additionalPhones,
} satisfies CompositeFieldLabels<FieldPhonesValue>,
[FieldMetadataType.RICH_TEXT_V2]: { [FieldMetadataType.RICH_TEXT_V2]: {
blocknoteLabel: blocknoteLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField
@ -79,7 +88,7 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
markdownLabel: markdownLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField
.markdown, .markdown,
} satisfies Partial<CompositeFieldLabels<FieldRichTextV2Value>>, } satisfies CompositeFieldLabels<FieldRichTextV2Value>,
[FieldMetadataType.ACTOR]: { [FieldMetadataType.ACTOR]: {
sourceLabel: sourceLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source, SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source,

View File

@ -3,14 +3,10 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport'; import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions'; import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { useIcons } from 'twenty-ui/display'; import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
type CompositeFieldType = keyof typeof COMPOSITE_FIELD_IMPORT_LABELS;
// Helper type for field validation type resolvers
type ValidationTypeResolver = (key: string, label: string) => FieldMetadataType;
export const useBuildAvailableFieldsForImport = () => { export const useBuildAvailableFieldsForImport = () => {
const { getIcon } = useIcons(); const { getIcon } = useIcons();
@ -39,22 +35,21 @@ export const useBuildAvailableFieldsForImport = () => {
const handleCompositeFieldWithLabels = ( const handleCompositeFieldWithLabels = (
fieldMetadataItem: FieldMetadataItem, fieldMetadataItem: FieldMetadataItem,
fieldType: CompositeFieldType, fieldType: CompositeFieldType,
validationTypeResolver?: ValidationTypeResolver,
) => { ) => {
Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach( Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach(
([key, subFieldLabel]) => { ([subFieldKey, subFieldLabel]) => {
const label = `${fieldMetadataItem.label} / ${subFieldLabel}`; const label = `${fieldMetadataItem.label} / ${subFieldLabel}`;
// Use the custom validation type if provided, otherwise use the field's type
const validationType = validationTypeResolver
? validationTypeResolver(key, subFieldLabel)
: fieldMetadataItem.type;
availableFieldsForImport.push( availableFieldsForImport.push(
createBaseField(fieldMetadataItem, { createBaseField(fieldMetadataItem, {
label, label,
key: `${subFieldLabel} (${fieldMetadataItem.name})`, key: `${subFieldLabel} (${fieldMetadataItem.name})`,
fieldValidationDefinitions: fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(validationType, label), getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
label,
subFieldKey,
),
}), }),
); );
}, },
@ -84,12 +79,6 @@ export const useBuildAvailableFieldsForImport = () => {
); );
}; };
// Special validation type resolver for currency fields
const currencyValidationResolver: ValidationTypeResolver = (key) =>
key === 'amountMicrosLabel'
? FieldMetadataType.NUMBER
: FieldMetadataType.CURRENCY;
const fieldTypeHandlers: Record< const fieldTypeHandlers: Record<
string, string,
(fieldMetadataItem: FieldMetadataItem) => void (fieldMetadataItem: FieldMetadataItem) => void
@ -134,7 +123,6 @@ export const useBuildAvailableFieldsForImport = () => {
handleCompositeFieldWithLabels( handleCompositeFieldWithLabels(
fieldMetadataItem, fieldMetadataItem,
FieldMetadataType.CURRENCY, FieldMetadataType.CURRENCY,
currencyValidationResolver,
); );
}, },
[FieldMetadataType.ACTOR]: (fieldMetadataItem) => { [FieldMetadataType.ACTOR]: (fieldMetadataItem) => {

View File

@ -39,6 +39,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
fieldMetadataItem.isActive && fieldMetadataItem.isActive &&
(!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') && (!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') &&
fieldMetadataItem.name !== 'createdAt' && fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'updatedAt' &&
(fieldMetadataItem.type !== FieldMetadataType.RELATION || (fieldMetadataItem.type !== FieldMetadataType.RELATION ||
fieldMetadataItem.relationDefinition?.direction === fieldMetadataItem.relationDefinition?.direction ===
RelationDefinitionType.MANY_TO_ONE), RelationDefinitionType.MANY_TO_ONE),

View File

@ -0,0 +1,408 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow';
import { ImportedStructuredRow } from '@/spreadsheet-import/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
describe('buildRecordFromImportedStructuredRow', () => {
it('should successfully build a record from imported structured row', () => {
const importedStructuredRow: ImportedStructuredRow<string> = {
booleanField: 'true',
numberField: '30',
multiSelectField: '["tag1", "tag2", "tag3"]',
relationField: 'company-123',
selectField: 'option1',
arrayField: '["item1", "item2", "item3"]',
jsonField: '{"key": "value", "nested": {"prop": "data"}}',
richTextField: 'Some rich text content',
dateField: '2023-12-25',
dateTimeField: '2023-12-25T10:30:00Z',
ratingField: '4',
'BlockNote (richTextField)': 'Rich content in blocknote format',
'Markdown (richTextField)': 'Content in markdown format',
'First Name (fullNameField)': 'John',
'Last Name (fullNameField)': 'Doe',
'Amount (currencyField)': '75',
'Currency (currencyField)': 'USD',
'Address 1 (addressField)': '123 Main St',
'Address 2 (addressField)': 'Apt 4B',
'City (addressField)': 'New York',
'Post Code (addressField)': '10001',
'State (addressField)': 'NY',
'Country (addressField)': 'USA',
'Primary Email (emailField)': 'john.doe@example.com',
'Additional Emails (emailField)':
'["john.doe+work@example.com", "j.doe@company.com"]',
'Primary Phone Number (phoneField)': '+1-555-0123',
'Primary Phone Country Code (phoneField)': 'US',
'Primary Phone Calling Code (phoneField)': '+1',
'Additional Phones (phoneField)':
'[{"number": "+1-555-0124", "callingCode": "+1", "countryCode": "US"}]',
'Link URL (linksField)': 'https://example.com',
'Link Label (linksField)': 'Example Website',
'Secondary Links (linksField)':
'[{"url": "https://github.com/user", "label": "GitHub"}]',
};
const fields: FieldMetadataItem[] = [
{
id: '3',
name: 'booleanField',
label: 'Boolean Field',
type: FieldMetadataType.BOOLEAN,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCheck',
description: null,
},
{
id: '4',
name: 'numberField',
label: 'Number Field',
type: FieldMetadataType.NUMBER,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconNumber',
description: null,
},
{
id: '5',
name: 'multiSelectField',
label: 'Multi-Select Field',
type: FieldMetadataType.MULTI_SELECT,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTag',
description: null,
options: [
{
id: '1',
value: 'tag1',
label: 'Tag 1',
color: 'blue',
position: 0,
},
{
id: '2',
value: 'tag2',
label: 'Tag 2',
color: 'red',
position: 1,
},
{
id: '3',
value: 'tag3',
label: 'Tag 3',
color: 'green',
position: 2,
},
],
},
{
id: '6',
name: 'relationField',
label: 'Relation Field',
type: FieldMetadataType.RELATION,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBuilding',
description: null,
},
{
id: '7',
name: 'fullNameField',
label: 'Full Name Field',
type: FieldMetadataType.FULL_NAME,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconUser',
description: null,
},
{
id: '8',
name: 'currencyField',
label: 'Currency Field',
type: FieldMetadataType.CURRENCY,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCurrencyDollar',
description: null,
},
{
id: '9',
name: 'addressField',
label: 'Address Field',
type: FieldMetadataType.ADDRESS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconMap',
description: null,
},
{
id: '10',
name: 'selectField',
label: 'Select Field',
type: FieldMetadataType.SELECT,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTag',
description: null,
options: [
{
id: '1',
value: 'option1',
label: 'Option 1',
color: 'blue',
position: 0,
},
{
id: '2',
value: 'option2',
label: 'Option 2',
color: 'red',
position: 1,
},
],
},
{
id: '11',
name: 'arrayField',
label: 'Array Field',
type: FieldMetadataType.ARRAY,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBracketsContain',
description: null,
},
{
id: '12',
name: 'jsonField',
label: 'JSON Field',
type: FieldMetadataType.RAW_JSON,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconBraces',
description: null,
},
{
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,
},
{
id: '14',
name: 'linksField',
label: 'Links Field',
type: FieldMetadataType.LINKS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconWorld',
description: null,
},
{
id: '15',
name: 'createdBy',
label: 'Created by',
type: FieldMetadataType.ACTOR,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconUsers',
description: null,
},
{
id: '16',
name: 'richTextField',
label: 'Rich Text Field',
type: FieldMetadataType.RICH_TEXT_V2,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconTextEditor',
description: null,
},
{
id: '17',
name: 'dateField',
label: 'Date Field',
type: FieldMetadataType.DATE,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCalendarEvent',
description: null,
},
{
id: '18',
name: 'dateTimeField',
label: 'Date Time Field',
type: FieldMetadataType.DATE_TIME,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconCalendarClock',
description: null,
},
{
id: '19',
name: 'ratingField',
label: 'Rating Field',
type: FieldMetadataType.RATING,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconStar',
description: null,
},
{
id: '20',
name: 'emailField',
label: 'Email Field',
type: FieldMetadataType.EMAILS,
isNullable: true,
isActive: true,
isCustom: false,
isSystem: false,
createdAt: '2023-01-01',
updatedAt: '2023-01-01',
icon: 'IconMail',
description: null,
},
];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fields,
});
expect(result).toEqual({
emailField: {
primaryEmail: 'john.doe@example.com',
additionalEmails: ['john.doe+work@example.com', 'j.doe@company.com'],
},
booleanField: true,
numberField: 30,
multiSelectField: ['tag1', 'tag2', 'tag3'],
relationFieldId: 'company-123',
selectField: 'option1',
arrayField: ['item1', 'item2', 'item3'],
jsonField: { key: 'value', nested: { prop: 'data' } },
fullNameField: {
firstName: 'John',
lastName: 'Doe',
},
currencyField: {
amountMicros: 75000000,
currencyCode: 'USD',
},
addressField: {
addressStreet1: '123 Main St',
addressStreet2: 'Apt 4B',
addressCity: 'New York',
addressPostcode: '10001',
addressState: 'NY',
addressCountry: 'USA',
},
phoneField: {
primaryPhoneNumber: '+1-555-0123',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
additionalPhones: [
{
number: '+1-555-0124',
callingCode: '+1',
countryCode: 'US',
},
],
},
linksField: {
primaryLinkUrl: 'https://example.com',
primaryLinkLabel: 'Example Website',
secondaryLinks: [
{
url: 'https://github.com/user',
label: 'GitHub',
},
],
},
createdBy: {
source: 'IMPORT',
context: {},
},
richTextField: {
blocknote: 'Rich content in blocknote format',
markdown: 'Content in markdown format',
},
dateField: '2023-12-25',
dateTimeField: '2023-12-25T10:30:00Z',
ratingField: '4',
});
});
});

View File

@ -20,10 +20,67 @@ type BuildRecordFromImportedStructuredRowArgs = {
importedStructuredRow: ImportedStructuredRow<any>; importedStructuredRow: ImportedStructuredRow<any>;
fields: FieldMetadataItem[]; fields: FieldMetadataItem[];
}; };
export const buildRecordFromImportedStructuredRow = ({ export const buildRecordFromImportedStructuredRow = ({
fields, fields,
importedStructuredRow, importedStructuredRow,
}: BuildRecordFromImportedStructuredRowArgs) => { }: BuildRecordFromImportedStructuredRowArgs) => {
const stringArrayJSONSchema = z
.preprocess((value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
}, z.array(z.string()))
.catch([]);
const linkArrayJSONSchema = z
.preprocess(
(value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
},
z.array(
z.object({
label: z.string().nullable(),
url: z.string().nullable(),
}),
),
)
.catch([]);
const phoneArrayJSONSchema = z
.preprocess(
(value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
},
z.array(
z.object({
number: z.string(),
callingCode: z.string(),
countryCode: z.string(),
}),
),
)
.catch([]);
const recordToBuild: Record<string, any> = {}; const recordToBuild: Record<string, any> = {};
const { const {
@ -37,9 +94,14 @@ export const buildRecordFromImportedStructuredRow = ({
}, },
CURRENCY: { amountMicrosLabel, currencyCodeLabel }, CURRENCY: { amountMicrosLabel, currencyCodeLabel },
FULL_NAME: { firstNameLabel, lastNameLabel }, FULL_NAME: { firstNameLabel, lastNameLabel },
LINKS: { primaryLinkUrlLabel }, LINKS: { primaryLinkUrlLabel, primaryLinkLabelLabel, secondaryLinksLabel },
EMAILS: { primaryEmailLabel }, EMAILS: { primaryEmailLabel, additionalEmailsLabel },
PHONES: { primaryPhoneNumberLabel, primaryPhoneCountryCodeLabel }, PHONES: {
primaryPhoneNumberLabel,
primaryPhoneCountryCodeLabel,
primaryPhoneCallingCodeLabel,
additionalPhonesLabel,
},
RICH_TEXT_V2: { blocknoteLabel, markdownLabel }, RICH_TEXT_V2: { blocknoteLabel, markdownLabel },
} = COMPOSITE_FIELD_IMPORT_LABELS; } = COMPOSITE_FIELD_IMPORT_LABELS;
@ -115,15 +177,23 @@ export const buildRecordFromImportedStructuredRow = ({
case FieldMetadataType.LINKS: { case FieldMetadataType.LINKS: {
if ( if (
isDefined( isDefined(
importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`], importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`] ||
importedStructuredRow[
`${primaryLinkLabelLabel} (${field.name})`
] ||
importedStructuredRow[`${secondaryLinksLabel} (${field.name})`],
) )
) { ) {
recordToBuild[field.name] = { recordToBuild[field.name] = {
primaryLinkLabel: '', primaryLinkLabel: castToString(
importedStructuredRow[`${primaryLinkLabelLabel} (${field.name})`],
),
primaryLinkUrl: castToString( primaryLinkUrl: castToString(
importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`], importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`],
), ),
secondaryLinks: [], secondaryLinks: linkArrayJSONSchema.parse(
importedStructuredRow[`${secondaryLinksLabel} (${field.name})`],
),
} satisfies FieldLinksValue; } satisfies FieldLinksValue;
} }
break; break;
@ -136,7 +206,11 @@ export const buildRecordFromImportedStructuredRow = ({
] || ] ||
importedStructuredRow[ importedStructuredRow[
`${primaryPhoneNumberLabel} (${field.name})` `${primaryPhoneNumberLabel} (${field.name})`
], ] ||
importedStructuredRow[
`${primaryPhoneCallingCodeLabel} (${field.name})`
] ||
importedStructuredRow[`${additionalPhonesLabel} (${field.name})`],
) )
) { ) {
recordToBuild[field.name] = { recordToBuild[field.name] = {
@ -150,7 +224,14 @@ export const buildRecordFromImportedStructuredRow = ({
`${primaryPhoneNumberLabel} (${field.name})` `${primaryPhoneNumberLabel} (${field.name})`
], ],
), ),
additionalPhones: null, primaryPhoneCallingCode: castToString(
importedStructuredRow[
`${primaryPhoneCallingCodeLabel} (${field.name})`
],
),
additionalPhones: phoneArrayJSONSchema.parse(
importedStructuredRow[`${additionalPhonesLabel} (${field.name})`],
),
} satisfies FieldPhonesValue; } satisfies FieldPhonesValue;
} }
break; break;
@ -177,13 +258,18 @@ export const buildRecordFromImportedStructuredRow = ({
if ( if (
isDefined( isDefined(
importedStructuredRow[`${primaryEmailLabel} (${field.name})`], importedStructuredRow[`${primaryEmailLabel} (${field.name})`],
) ||
isDefined(
importedStructuredRow[`${additionalEmailsLabel} (${field.name})`],
) )
) { ) {
recordToBuild[field.name] = { recordToBuild[field.name] = {
primaryEmail: castToString( primaryEmail: castToString(
importedStructuredRow[`${primaryEmailLabel} (${field.name})`], importedStructuredRow[`${primaryEmailLabel} (${field.name})`],
), ),
additionalEmails: null, additionalEmails: stringArrayJSONSchema.parse(
importedStructuredRow[`${additionalEmailsLabel} (${field.name})`],
),
} satisfies FieldEmailsValue; } satisfies FieldEmailsValue;
} }
break; break;
@ -219,19 +305,6 @@ export const buildRecordFromImportedStructuredRow = ({
break; break;
case FieldMetadataType.ARRAY: case FieldMetadataType.ARRAY:
case FieldMetadataType.MULTI_SELECT: { case FieldMetadataType.MULTI_SELECT: {
const stringArrayJSONSchema = z
.preprocess((value) => {
try {
if (typeof value !== 'string') {
return [];
}
return JSON.parse(value);
} catch {
return [];
}
}, z.array(z.string()))
.catch([]);
recordToBuild[field.name] = recordToBuild[field.name] =
stringArrayJSONSchema.parse(importedFieldValue); stringArrayJSONSchema.parse(importedFieldValue);
break; break;

View File

@ -1,62 +1,216 @@
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema';
import { SpreadsheetImportFieldValidationDefinition } from '@/spreadsheet-import/types'; 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 { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
const getNumberValidationDefinition = (
fieldName: string,
): SpreadsheetImportFieldValidationDefinition => ({
rule: 'function',
isValid: (value: string) => !isNaN(+value),
errorMessage: `${fieldName} ${t`must be a number`}`,
level: 'error',
});
export const getSpreadSheetFieldValidationDefinitions = ( export const getSpreadSheetFieldValidationDefinitions = (
type: FieldMetadataType, type: FieldMetadataType,
fieldName: string, fieldName: string,
subFieldKey?: string,
): SpreadsheetImportFieldValidationDefinition[] => { ): SpreadsheetImportFieldValidationDefinition[] => {
switch (type) { switch (type) {
case FieldMetadataType.FULL_NAME:
return [
{
rule: 'object',
isValid: ({
firstName,
lastName,
}: {
firstName: string;
lastName: string;
}) => {
return (
isDefined(firstName) &&
isDefined(lastName) &&
typeof firstName === 'string' &&
typeof lastName === 'string'
);
},
errorMessage: fieldName + ' must be a full name',
level: 'error',
},
];
case FieldMetadataType.NUMBER: case FieldMetadataType.NUMBER:
return [ return [getNumberValidationDefinition(fieldName)];
{ case FieldMetadataType.UUID:
rule: 'function',
isValid: (value: string) => !isNaN(+value),
errorMessage: fieldName + ' is not valid',
level: 'error',
},
];
case FieldMetadataType.RELATION: case FieldMetadataType.RELATION:
return [ return [
{ {
rule: 'function', rule: 'function',
isValid: (value: string) => isValidUuid(value), isValid: (value: string) => isValidUuid(value),
errorMessage: fieldName + ' is not valid', errorMessage: `${fieldName} ${t`is not a valid UUID`}`,
level: 'error', level: 'error',
}, },
]; ];
case FieldMetadataType.CURRENCY:
switch (subFieldKey) {
case 'amountMicrosLabel':
return [getNumberValidationDefinition(fieldName)];
default:
return [];
}
case FieldMetadataType.EMAILS:
switch (subFieldKey) {
case 'primaryEmailLabel':
return [
{
rule: 'function',
isValid: (email: string) => emailSchema.safeParse(email).success,
errorMessage: `${fieldName} ${t`is not a valid email`}`,
level: 'error',
},
];
case 'additionalEmailsLabel':
return [
{
rule: 'function',
isValid: (stringifiedAdditionalEmails: string) => {
if (!isDefined(stringifiedAdditionalEmails)) return true;
try {
const additionalEmails = JSON.parse(
stringifiedAdditionalEmails,
);
return additionalEmails.every(
(email: string) => emailSchema.safeParse(email).success,
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of valid emails`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.LINKS: case FieldMetadataType.LINKS:
switch (subFieldKey) {
case 'primaryLinkUrlLabel':
return [
{
rule: 'function',
isValid: (primaryLinkUrl: string) => {
if (!isDefined(primaryLinkUrl)) return true;
return absoluteUrlSchema.safeParse(primaryLinkUrl).success;
},
errorMessage: `${fieldName} ${t`is not a valid URL`}`,
level: 'error',
},
];
case 'secondaryLinksLabel':
return [
{
rule: 'function',
isValid: (stringifiedSecondaryLinks: string) => {
if (!isDefined(stringifiedSecondaryLinks)) return true;
try {
const secondaryLinks = JSON.parse(stringifiedSecondaryLinks);
return secondaryLinks.every((link: { url: string }) => {
if (!isDefined(link.url)) return true;
return absoluteUrlSchema.safeParse(link.url).success;
});
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of object with valid url and label (format: '[{"url":"valid.url", "label":"label value")}]'`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.DATE_TIME:
return [ return [
{ {
rule: 'object', rule: 'function',
isValid: ({ isValid: (value: string) => {
primaryLinkUrl, const date = new Date(value);
}: Pick<FieldLinksValue, 'primaryLinkUrl' | 'secondaryLinks'>) => return isDate(date) && !isNaN(date.getTime());
absoluteUrlSchema.safeParse(primaryLinkUrl).success, },
errorMessage: fieldName + ' is not valid', errorMessage: `${fieldName} ${t`is not a valid date time (format: '2021-12-01T00:00:00Z')`}`,
level: 'error',
},
];
case FieldMetadataType.DATE:
return [
{
rule: 'function',
isValid: (value: string) => {
const date = new Date(value);
return isDate(date) && !isNaN(date.getTime());
},
errorMessage: `${fieldName} ${t`is not a valid date (format: '2021-12-01')`}`,
level: 'error',
},
];
case FieldMetadataType.PHONES:
switch (subFieldKey) {
case 'primaryPhoneNumberLabel':
return [
{
rule: 'regex',
value: '^[0-9]+$',
errorMessage: `${fieldName} ${t`must contain only numbers`}`,
level: 'error',
},
];
case 'additionalPhonesLabel':
return [
{
rule: 'function',
isValid: (stringifiedAdditionalPhones: string) => {
if (!isDefined(stringifiedAdditionalPhones)) return true;
try {
const additionalPhones = JSON.parse(
stringifiedAdditionalPhones,
);
return additionalPhones.every(
(phone: {
number: string;
callingCode: string;
countryCode: string;
}) =>
isDefined(phone.number) &&
/^[0-9]+$/.test(phone.number) &&
isDefined(phone.callingCode) &&
isDefined(phone.countryCode),
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`must be an array of object with valid phone, calling code and country code (format: '[{"number":"123456789", "callingCode":"+33", "countryCode":"FR"}]')`}`,
level: 'error',
},
];
default:
return [];
}
case FieldMetadataType.RAW_JSON:
return [
{
rule: 'function',
isValid: (value: string) => {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`is not a valid JSON`}`,
level: 'error',
},
];
case FieldMetadataType.ARRAY:
return [
{
rule: 'function',
isValid: (value: string) => {
try {
const parsedValue = JSON.parse(value);
return (
Array.isArray(parsedValue) &&
parsedValue.every((item: any) => isString(item))
);
} catch {
return false;
}
},
errorMessage: `${fieldName} ${t`is not a valid array`}`,
level: 'error', level: 'error',
}, },
]; ];

View File

@ -9,6 +9,7 @@ import {
} from '@/spreadsheet-import/types'; } from '@/spreadsheet-import/types';
import { TextInput } from '@/ui/input/components/TextInput'; import { TextInput } from '@/ui/input/components/TextInput';
import camelCase from 'lodash.camelcase';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { AppTooltip } from 'twenty-ui/display'; import { AppTooltip } from 'twenty-ui/display';
import { Checkbox, CheckboxVariant, Toggle } from 'twenty-ui/input'; import { Checkbox, CheckboxVariant, Toggle } from 'twenty-ui/input';
@ -66,6 +67,10 @@ const StyledSelectReadonlyValueContianer = styled.div`
const SELECT_COLUMN_KEY = 'select-row'; const SELECT_COLUMN_KEY = 'select-row';
const formatSafeId = (columnKey: string) => {
return camelCase(columnKey.replace('(', '').replace(')', ''));
};
export const generateColumns = <T extends string>( export const generateColumns = <T extends string>(
fields: SpreadsheetImportFields<T>, fields: SpreadsheetImportFields<T>,
): Column<ImportedStructuredRow<T> & ImportedStructuredRowMetadata>[] => [ ): Column<ImportedStructuredRow<T> & ImportedStructuredRowMetadata>[] => [
@ -110,13 +115,13 @@ export const generateColumns = <T extends string>(
resizable: true, resizable: true,
headerRenderer: () => ( headerRenderer: () => (
<StyledHeaderContainer> <StyledHeaderContainer>
<StyledHeaderLabel id={`${column.key}`}> <StyledHeaderLabel id={formatSafeId(column.key)}>
{column.label} {column.label}
</StyledHeaderLabel> </StyledHeaderLabel>
{column.description && {column.description &&
createPortal( createPortal(
<AppTooltip <AppTooltip
anchorSelect={`#${column.key}`} anchorSelect={`#${formatSafeId(column.key)}`}
place="top" place="top"
content={column.description} content={column.description}
/>, />,
@ -168,7 +173,7 @@ export const generateColumns = <T extends string>(
case 'checkbox': case 'checkbox':
component = ( component = (
<StyledToggleContainer <StyledToggleContainer
id={`${columnKey}-${row.__index}`} id={formatSafeId(`${columnKey}-${row.__index}`)}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
}} }}
@ -187,7 +192,9 @@ export const generateColumns = <T extends string>(
break; break;
case 'select': case 'select':
component = ( component = (
<StyledDefaultContainer id={`${columnKey}-${row.__index}`}> <StyledDefaultContainer
id={formatSafeId(`${columnKey}-${row.__index}`)}
>
{column.fieldType.options.find( {column.fieldType.options.find(
(option) => option.value === row[columnKey as T], (option) => option.value === row[columnKey as T],
)?.label || null} )?.label || null}
@ -196,7 +203,9 @@ export const generateColumns = <T extends string>(
break; break;
default: default:
component = ( component = (
<StyledDefaultContainer id={`${columnKey}-${row.__index}`}> <StyledDefaultContainer
id={formatSafeId(`${columnKey}-${row.__index}`)}
>
{row[columnKey]} {row[columnKey]}
</StyledDefaultContainer> </StyledDefaultContainer>
); );
@ -208,7 +217,7 @@ export const generateColumns = <T extends string>(
{component} {component}
{createPortal( {createPortal(
<AppTooltip <AppTooltip
anchorSelect={`#${columnKey}-${row.__index}`} anchorSelect={`#${formatSafeId(`${columnKey}-${row.__index}`)}`}
place="top" place="top"
content={row.__errors?.[columnKey]?.message} content={row.__errors?.[columnKey]?.message}
/>, />,