diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts index 26c630d31..2db9a5443 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts @@ -53,17 +53,20 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = { primaryLinkUrlLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField .primaryLinkUrl, + primaryLinkLabelLabel: + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField + .primaryLinkLabel, secondaryLinksLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.LINKS.labelBySubField .secondaryLinks, - } satisfies Partial>, + } satisfies CompositeFieldLabels, [FieldMetadataType.EMAILS]: { primaryEmailLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail, additionalEmailsLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField .additionalEmails, - } satisfies Partial>, + } satisfies CompositeFieldLabels, [FieldMetadataType.PHONES]: { primaryPhoneCountryCodeLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField @@ -71,7 +74,13 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = { primaryPhoneNumberLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField .primaryPhoneNumber, - } satisfies Partial>, + primaryPhoneCallingCodeLabel: + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField + .primaryPhoneCallingCode, + additionalPhonesLabel: + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField + .additionalPhones, + } satisfies CompositeFieldLabels, [FieldMetadataType.RICH_TEXT_V2]: { blocknoteLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField @@ -79,7 +88,7 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = { markdownLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField .markdown, - } satisfies Partial>, + } satisfies CompositeFieldLabels, [FieldMetadataType.ACTOR]: { sourceLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts index 2eefded44..607c167ff 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts @@ -3,14 +3,10 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport'; import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions'; +import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType'; import { useIcons } from 'twenty-ui/display'; 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 = () => { const { getIcon } = useIcons(); @@ -39,22 +35,21 @@ export const useBuildAvailableFieldsForImport = () => { const handleCompositeFieldWithLabels = ( fieldMetadataItem: FieldMetadataItem, fieldType: CompositeFieldType, - validationTypeResolver?: ValidationTypeResolver, ) => { Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach( - ([key, subFieldLabel]) => { + ([subFieldKey, 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( createBaseField(fieldMetadataItem, { label, key: `${subFieldLabel} (${fieldMetadataItem.name})`, 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< string, (fieldMetadataItem: FieldMetadataItem) => void @@ -134,7 +123,6 @@ export const useBuildAvailableFieldsForImport = () => { handleCompositeFieldWithLabels( fieldMetadataItem, FieldMetadataType.CURRENCY, - currencyValidationResolver, ); }, [FieldMetadataType.ACTOR]: (fieldMetadataItem) => { diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts index deff8a0ba..583b896d6 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog.ts @@ -39,6 +39,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( fieldMetadataItem.isActive && (!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') && fieldMetadataItem.name !== 'createdAt' && + fieldMetadataItem.name !== 'updatedAt' && (fieldMetadataItem.type !== FieldMetadataType.RELATION || fieldMetadataItem.relationDefinition?.direction === RelationDefinitionType.MANY_TO_ONE), diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/buildRecordFromImportedStructuredRow.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/buildRecordFromImportedStructuredRow.test.ts new file mode 100644 index 000000000..c702a7ec6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/__tests__/buildRecordFromImportedStructuredRow.test.ts @@ -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 = { + 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', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts index 156bcc4ba..05647babf 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow.ts @@ -20,10 +20,67 @@ type BuildRecordFromImportedStructuredRowArgs = { importedStructuredRow: ImportedStructuredRow; fields: FieldMetadataItem[]; }; + export const buildRecordFromImportedStructuredRow = ({ fields, importedStructuredRow, }: 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 = {}; const { @@ -37,9 +94,14 @@ export const buildRecordFromImportedStructuredRow = ({ }, CURRENCY: { amountMicrosLabel, currencyCodeLabel }, FULL_NAME: { firstNameLabel, lastNameLabel }, - LINKS: { primaryLinkUrlLabel }, - EMAILS: { primaryEmailLabel }, - PHONES: { primaryPhoneNumberLabel, primaryPhoneCountryCodeLabel }, + LINKS: { primaryLinkUrlLabel, primaryLinkLabelLabel, secondaryLinksLabel }, + EMAILS: { primaryEmailLabel, additionalEmailsLabel }, + PHONES: { + primaryPhoneNumberLabel, + primaryPhoneCountryCodeLabel, + primaryPhoneCallingCodeLabel, + additionalPhonesLabel, + }, RICH_TEXT_V2: { blocknoteLabel, markdownLabel }, } = COMPOSITE_FIELD_IMPORT_LABELS; @@ -115,15 +177,23 @@ export const buildRecordFromImportedStructuredRow = ({ case FieldMetadataType.LINKS: { if ( isDefined( - importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`], + importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`] || + importedStructuredRow[ + `${primaryLinkLabelLabel} (${field.name})` + ] || + importedStructuredRow[`${secondaryLinksLabel} (${field.name})`], ) ) { recordToBuild[field.name] = { - primaryLinkLabel: '', + primaryLinkLabel: castToString( + importedStructuredRow[`${primaryLinkLabelLabel} (${field.name})`], + ), primaryLinkUrl: castToString( importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`], ), - secondaryLinks: [], + secondaryLinks: linkArrayJSONSchema.parse( + importedStructuredRow[`${secondaryLinksLabel} (${field.name})`], + ), } satisfies FieldLinksValue; } break; @@ -136,7 +206,11 @@ export const buildRecordFromImportedStructuredRow = ({ ] || importedStructuredRow[ `${primaryPhoneNumberLabel} (${field.name})` - ], + ] || + importedStructuredRow[ + `${primaryPhoneCallingCodeLabel} (${field.name})` + ] || + importedStructuredRow[`${additionalPhonesLabel} (${field.name})`], ) ) { recordToBuild[field.name] = { @@ -150,7 +224,14 @@ export const buildRecordFromImportedStructuredRow = ({ `${primaryPhoneNumberLabel} (${field.name})` ], ), - additionalPhones: null, + primaryPhoneCallingCode: castToString( + importedStructuredRow[ + `${primaryPhoneCallingCodeLabel} (${field.name})` + ], + ), + additionalPhones: phoneArrayJSONSchema.parse( + importedStructuredRow[`${additionalPhonesLabel} (${field.name})`], + ), } satisfies FieldPhonesValue; } break; @@ -177,13 +258,18 @@ export const buildRecordFromImportedStructuredRow = ({ if ( isDefined( importedStructuredRow[`${primaryEmailLabel} (${field.name})`], + ) || + isDefined( + importedStructuredRow[`${additionalEmailsLabel} (${field.name})`], ) ) { recordToBuild[field.name] = { primaryEmail: castToString( importedStructuredRow[`${primaryEmailLabel} (${field.name})`], ), - additionalEmails: null, + additionalEmails: stringArrayJSONSchema.parse( + importedStructuredRow[`${additionalEmailsLabel} (${field.name})`], + ), } satisfies FieldEmailsValue; } break; @@ -219,19 +305,6 @@ export const buildRecordFromImportedStructuredRow = ({ break; case FieldMetadataType.ARRAY: 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] = stringArrayJSONSchema.parse(importedFieldValue); break; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts index 4ff296a22..777483767 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions.ts @@ -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 { t } from '@lingui/core/macro'; +import { isDate, isString } from '@sniptt/guards'; import { absoluteUrlSchema, isDefined, isValidUuid } from 'twenty-shared/utils'; 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 = ( type: FieldMetadataType, fieldName: string, + subFieldKey?: string, ): SpreadsheetImportFieldValidationDefinition[] => { 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: - return [ - { - rule: 'function', - isValid: (value: string) => !isNaN(+value), - errorMessage: fieldName + ' is not valid', - level: 'error', - }, - ]; + return [getNumberValidationDefinition(fieldName)]; + case FieldMetadataType.UUID: case FieldMetadataType.RELATION: return [ { rule: 'function', isValid: (value: string) => isValidUuid(value), - errorMessage: fieldName + ' is not valid', + errorMessage: `${fieldName} ${t`is not a valid UUID`}`, 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: + 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 [ { - rule: 'object', - isValid: ({ - primaryLinkUrl, - }: Pick) => - absoluteUrlSchema.safeParse(primaryLinkUrl).success, - errorMessage: fieldName + ' is not valid', + rule: 'function', + isValid: (value: string) => { + const date = new Date(value); + return isDate(date) && !isNaN(date.getTime()); + }, + 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', }, ]; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx index 41105d3c0..520dbed82 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx @@ -9,6 +9,7 @@ import { } from '@/spreadsheet-import/types'; import { TextInput } from '@/ui/input/components/TextInput'; +import camelCase from 'lodash.camelcase'; import { isDefined } from 'twenty-shared/utils'; import { AppTooltip } from 'twenty-ui/display'; import { Checkbox, CheckboxVariant, Toggle } from 'twenty-ui/input'; @@ -66,6 +67,10 @@ const StyledSelectReadonlyValueContianer = styled.div` const SELECT_COLUMN_KEY = 'select-row'; +const formatSafeId = (columnKey: string) => { + return camelCase(columnKey.replace('(', '').replace(')', '')); +}; + export const generateColumns = ( fields: SpreadsheetImportFields, ): Column & ImportedStructuredRowMetadata>[] => [ @@ -110,13 +115,13 @@ export const generateColumns = ( resizable: true, headerRenderer: () => ( - + {column.label} {column.description && createPortal( , @@ -168,7 +173,7 @@ export const generateColumns = ( case 'checkbox': component = ( { event.stopPropagation(); }} @@ -187,7 +192,9 @@ export const generateColumns = ( break; case 'select': component = ( - + {column.fieldType.options.find( (option) => option.value === row[columnKey as T], )?.label || null} @@ -196,7 +203,9 @@ export const generateColumns = ( break; default: component = ( - + {row[columnKey]} ); @@ -208,7 +217,7 @@ export const generateColumns = ( {component} {createPortal( ,