diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx index e693d160628..3e800195055 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx @@ -90,9 +90,9 @@ export const AdvancedFilterSubFieldSelectMenu = ({ return null; } - const subFieldNames = - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[objectFilterDropdownSubMenuFieldType] - .filterableSubFields; + const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[ + objectFilterDropdownSubMenuFieldType + ].subFields.map((subField) => subField.subFieldName); const subFieldsAreFilterable = isDefined(fieldMetadataItemUsedInDropdown) && diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel.ts index 36c6f04a46c..fa5fc9066ce 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel.ts @@ -3,10 +3,11 @@ import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldTy export const getCompositeSubFieldLabel = ( compositeFieldType: CompositeFieldType, - subFieldName: (typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS)[CompositeFieldType]['subFields'][number], + subFieldName: (typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS)[CompositeFieldType]['subFields'][number]['subFieldName'], ): string => { return ( - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType] - .labelBySubField as any - )[subFieldName]; + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.find( + (subField) => subField.subFieldName === subFieldName, + )?.subFieldLabel || '' + ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isExpectedSubFieldName.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isExpectedSubFieldName.ts index 65cf31e0490..8074ec708a4 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isExpectedSubFieldName.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isExpectedSubFieldName.ts @@ -5,16 +5,15 @@ export const isExpectedSubFieldName = < CompositeFieldTypeSettings extends typeof SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS, PossibleSubFieldsForGivenFieldType extends - CompositeFieldTypeSettings[GivenFieldType]['subFields'][number], + CompositeFieldTypeSettings[GivenFieldType]['subFields'][number]['subFieldName'], >( fieldMetadataType: GivenFieldType, subFieldName: PossibleSubFieldsForGivenFieldType, subFieldNameToCheck: string | null | undefined, ): subFieldNameToCheck is PossibleSubFieldsForGivenFieldType => { return ( - ( - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataType] - .subFields as string[] - ).includes(subFieldName) && subFieldName === subFieldNameToCheck + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataType].subFields + .map((subField) => subField.subFieldName) + .includes(subFieldName) && subFieldName === subFieldNameToCheck ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts index 6b5838881ba..3db1e131c89 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportRecords.ts @@ -12,6 +12,7 @@ import { import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; +import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue'; import { t } from '@lingui/core/macro'; import { saveAs } from 'file-saver'; import { isDefined } from 'twenty-shared/utils'; @@ -59,9 +60,9 @@ export const generateCsv: GenerateExport = ({ const keys = columnsToExportWithIdColumn.flatMap((col) => { const column = { field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`, - title: [col.label, col.type === 'RELATION' ? 'Id' : null] - .filter(isDefined) - .join(' '), + title: escapeCSVValue( + `${col.label}${col.type === 'RELATION' ? ' Id' : ''}`, + ), }; const columnType = col.type; 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 deleted file mode 100644 index 2db9a5443ff..00000000000 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - FieldActorValue, - FieldAddressValue, - FieldCurrencyValue, - FieldEmailsValue, - FieldFullNameValue, - FieldLinksValue, - FieldPhonesValue, - FieldRichTextV2Value, -} from '@/object-record/record-field/types/FieldMetadata'; -import { CompositeFieldLabels } from '@/object-record/spreadsheet-import/types/CompositeFieldLabels'; -import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const COMPOSITE_FIELD_IMPORT_LABELS = { - [FieldMetadataType.FULL_NAME]: { - firstNameLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.FULL_NAME.labelBySubField.firstName, - lastNameLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.FULL_NAME.labelBySubField.lastName, - } satisfies CompositeFieldLabels, - [FieldMetadataType.CURRENCY]: { - currencyCodeLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField - .currencyCode, - amountMicrosLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField - .amountMicros, - } satisfies CompositeFieldLabels, - [FieldMetadataType.ADDRESS]: { - addressStreet1Label: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField - .addressStreet1, - addressStreet2Label: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField - .addressStreet2, - addressCityLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField.addressCity, - addressPostcodeLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField - .addressPostcode, - addressStateLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField - .addressState, - addressCountryLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ADDRESS.labelBySubField - .addressCountry, - } satisfies Omit< - CompositeFieldLabels, - 'addressLatLabel' | 'addressLngLabel' - >, - [FieldMetadataType.LINKS]: { - 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 CompositeFieldLabels, - [FieldMetadataType.EMAILS]: { - primaryEmailLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail, - additionalEmailsLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField - .additionalEmails, - } satisfies CompositeFieldLabels, - [FieldMetadataType.PHONES]: { - primaryPhoneCountryCodeLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField - .primaryPhoneCountryCode, - primaryPhoneNumberLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.PHONES.labelBySubField - .primaryPhoneNumber, - 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 - .blocknote, - markdownLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.RICH_TEXT_V2.labelBySubField - .markdown, - } satisfies CompositeFieldLabels, - [FieldMetadataType.ACTOR]: { - sourceLabel: - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source, - nameLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.name, - } satisfies Partial>, -}; 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 607c167ff12..135176e7622 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 @@ -1,8 +1,8 @@ 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 { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType'; import { useIcons } from 'twenty-ui/display'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -36,8 +36,9 @@ export const useBuildAvailableFieldsForImport = () => { fieldMetadataItem: FieldMetadataItem, fieldType: CompositeFieldType, ) => { - Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach( - ([subFieldKey, subFieldLabel]) => { + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldType].subFields.forEach( + ({ subFieldName, subFieldLabel, isImportable }) => { + if (!isImportable) return; const label = `${fieldMetadataItem.label} / ${subFieldLabel}`; availableFieldsForImport.push( @@ -48,7 +49,7 @@ export const useBuildAvailableFieldsForImport = () => { getSpreadSheetFieldValidationDefinitions( fieldMetadataItem.type, label, - subFieldKey, + subFieldName, ), }), ); 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 c1db47fc2fa..f48cfd23211 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 @@ -2,11 +2,12 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useBuildAvailableFieldsForImport } from '@/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport'; import { buildRecordFromImportedStructuredRow } from '@/object-record/spreadsheet-import/utils/buildRecordFromImportedStructuredRow'; +import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; export const useOpenObjectRecordsSpreadsheetImportDialog = ( objectNameSingular: string, @@ -30,22 +31,20 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( 'fields' | 'isOpen' | 'onClose' >, ) => { - const availableFieldMetadataItems = objectMetadataItem.fields - .filter( - (fieldMetadataItem) => - fieldMetadataItem.isActive && - (!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') && - fieldMetadataItem.name !== 'createdAt' && - fieldMetadataItem.name !== 'updatedAt' && - (fieldMetadataItem.type !== FieldMetadataType.RELATION || - fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE), - ) - .sort((fieldMetadataItemA, fieldMetadataItemB) => - fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), + //All fields that can be imported (included matchable and auto-filled) + const availableFieldMetadataItemsToImport = + spreadsheetImportFilterAvailableFieldMetadataItems( + objectMetadataItem.fields, ); - const availableFields = buildAvailableFieldsForImport( - availableFieldMetadataItems, + const availableFieldMetadataItemsForMatching = + availableFieldMetadataItemsToImport.filter( + (fieldMetadataItem) => + fieldMetadataItem.type !== FieldMetadataType.ACTOR, + ); + + const availableFieldsForMatching = buildAvailableFieldsForImport( + availableFieldMetadataItemsForMatching, ); openSpreadsheetImportDialog({ @@ -55,7 +54,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( const fieldMapping: Record = buildRecordFromImportedStructuredRow({ importedStructuredRow: record, - fields: availableFieldMetadataItems, + fields: availableFieldMetadataItemsToImport, }); return fieldMapping; @@ -70,8 +69,8 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = ( }); } }, - fields: availableFields, - availableFieldMetadataItems, + fields: availableFieldsForMatching, + availableFieldMetadataItems: availableFieldMetadataItemsToImport, }); }; 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 05647babfac..4f93d5f4b8f 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 @@ -7,7 +7,7 @@ import { FieldPhonesValue, FieldRichTextV2Value, } from '@/object-record/record-field/types/FieldMetadata'; -import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; +import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; @@ -85,25 +85,35 @@ export const buildRecordFromImportedStructuredRow = ({ const { ADDRESS: { - addressCityLabel, - addressCountryLabel, - addressPostcodeLabel, - addressStateLabel, - addressStreet1Label, - addressStreet2Label, + 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, }, - CURRENCY: { amountMicrosLabel, currencyCodeLabel }, - FULL_NAME: { firstNameLabel, lastNameLabel }, - LINKS: { primaryLinkUrlLabel, primaryLinkLabelLabel, secondaryLinksLabel }, - EMAILS: { primaryEmailLabel, additionalEmailsLabel }, PHONES: { - primaryPhoneNumberLabel, - primaryPhoneCountryCodeLabel, - primaryPhoneCallingCodeLabel, - additionalPhonesLabel, + primaryPhoneNumber: primaryPhoneNumberLabel, + primaryPhoneCountryCode: primaryPhoneCountryCodeLabel, + primaryPhoneCallingCode: primaryPhoneCallingCodeLabel, + additionalPhones: additionalPhonesLabel, }, - RICH_TEXT_V2: { blocknoteLabel, markdownLabel }, - } = COMPOSITE_FIELD_IMPORT_LABELS; + RICH_TEXT_V2: { blocknote: blocknoteLabel, markdown: markdownLabel }, + } = COMPOSITE_FIELD_SUB_FIELD_LABELS; for (const field of fields) { const importedFieldValue = importedStructuredRow[field.name]; @@ -274,10 +284,18 @@ export const buildRecordFromImportedStructuredRow = ({ } break; } + case FieldMetadataType.UUID: + if ( + isDefined(importedFieldValue) && + isNonEmptyString(importedFieldValue) + ) { + recordToBuild[field.name] = importedFieldValue; + } + break; case FieldMetadataType.RELATION: if ( isDefined(importedFieldValue) && - (isNonEmptyString(importedFieldValue) || importedFieldValue !== false) + isNonEmptyString(importedFieldValue) ) { recordToBuild[field.name + 'Id'] = importedFieldValue; } 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 fba6efedd3b..b3aadea9c80 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 @@ -35,14 +35,14 @@ export const getSpreadSheetFieldValidationDefinitions = ( ]; case FieldMetadataType.CURRENCY: switch (subFieldKey) { - case 'amountMicrosLabel': + case 'amountMicros': return [getNumberValidationDefinition(fieldName)]; default: return []; } case FieldMetadataType.EMAILS: switch (subFieldKey) { - case 'primaryEmailLabel': + case 'primaryEmail': return [ { rule: 'function', @@ -51,7 +51,7 @@ export const getSpreadSheetFieldValidationDefinitions = ( level: 'error', }, ]; - case 'additionalEmailsLabel': + case 'additionalEmails': return [ { rule: 'function', @@ -77,7 +77,7 @@ export const getSpreadSheetFieldValidationDefinitions = ( } case FieldMetadataType.LINKS: switch (subFieldKey) { - case 'primaryLinkUrlLabel': + case 'primaryLinkUrl': return [ { rule: 'function', @@ -89,7 +89,7 @@ export const getSpreadSheetFieldValidationDefinitions = ( level: 'error', }, ]; - case 'secondaryLinksLabel': + case 'secondaryLinks': return [ { rule: 'function', @@ -139,7 +139,7 @@ export const getSpreadSheetFieldValidationDefinitions = ( ]; case FieldMetadataType.PHONES: switch (subFieldKey) { - case 'primaryPhoneNumberLabel': + case 'primaryPhoneNumber': return [ { rule: 'regex', @@ -148,7 +148,7 @@ export const getSpreadSheetFieldValidationDefinitions = ( level: 'error', }, ]; - case 'additionalPhonesLabel': + case 'additionalPhones': return [ { rule: 'function', diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSubFieldOptionKey.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSubFieldOptionKey.ts index 1af94727d5b..b9f0d78d546 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSubFieldOptionKey.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/getSubFieldOptionKey.ts @@ -1,15 +1,19 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; +import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; export const getSubFieldOptionKey = ( fieldMetadataItem: FieldMetadataItem, subFieldName: string, ) => { - const subFieldNameLabelKey = `${subFieldName}Label`; + if (!isCompositeFieldType(fieldMetadataItem.type)) { + throw new Error( + `getSubFieldOptionKey can only be called for composite field types. Received: ${fieldMetadataItem.type}`, + ); + } - const subFieldLabel = ( - (COMPOSITE_FIELD_IMPORT_LABELS as any)[fieldMetadataItem.type] as any - )[subFieldNameLabelKey]; + const subFieldLabel = + COMPOSITE_FIELD_SUB_FIELD_LABELS[fieldMetadataItem.type][subFieldName]; const subFieldKey = `${subFieldLabel} (${fieldMetadataItem.name})`; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts.ts new file mode 100644 index 00000000000..f91bd066ace --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts.ts @@ -0,0 +1,23 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationType } from '~/generated-metadata/graphql'; + +export const spreadsheetImportFilterAvailableFieldMetadataItems = ( + fields: FieldMetadataItem[], +) => { + return fields + .filter( + (fieldMetadataItem) => + fieldMetadataItem.isActive && + (!fieldMetadataItem.isSystem || fieldMetadataItem.name === 'id') && + fieldMetadataItem.name !== 'createdAt' && + fieldMetadataItem.name !== 'updatedAt' && + (![FieldMetadataType.RELATION, FieldMetadataType.RICH_TEXT].includes( + fieldMetadataItem.type, + ) || + fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE), + ) + .sort((fieldMetadataItemA, fieldMetadataItemB) => + fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/AllSubFields.ts b/packages/twenty-front/src/modules/settings/data-model/constants/AllSubFields.ts index 307d935dfc5..f92b4f65241 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/AllSubFields.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/AllSubFields.ts @@ -3,5 +3,7 @@ import { COMPOSITE_FIELD_TYPES } from '@/settings/data-model/types/CompositeFiel export const ALL_SUB_FIELDS = COMPOSITE_FIELD_TYPES.flatMap( (compositeFieldType) => - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields, + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.map( + (subField) => subField.subFieldName, + ), ); diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index 5b92fa58d6f..f525810c40f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -25,11 +25,16 @@ import { } from 'twenty-ui/display'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +type CompositeSubFieldConfig = { + subFieldName: keyof T; + subFieldLabel: string; + isImportable: boolean; + isFilterable: boolean; +}; + export type SettingsCompositeFieldTypeConfig = SettingsFieldTypeConfig & { - subFields: (keyof T)[]; - filterableSubFields: (keyof T)[]; - labelBySubField: Record; - exampleValue: T; + subFields: CompositeSubFieldConfig[]; + exampleValues: [T, T, T]; }; type SettingsCompositeFieldTypeConfigArray = Record< @@ -41,215 +46,415 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { [FieldMetadataType.CURRENCY]: { label: 'Currency', Icon: IllustrationIconCurrency, - subFields: ['amountMicros', 'currencyCode'], - filterableSubFields: ['amountMicros', 'currencyCode'], - labelBySubField: { - amountMicros: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] - .amountMicros, - currencyCode: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] - .currencyCode, - }, - exampleValue: { - amountMicros: 2000000000, - currencyCode: CurrencyCode.USD, - }, + subFields: [ + { + subFieldName: 'amountMicros', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] + .amountMicros, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'currencyCode', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] + .currencyCode, + isImportable: true, + isFilterable: true, + }, + ], + exampleValues: [ + { + amountMicros: 2000000000, + currencyCode: CurrencyCode.USD, + }, + { + amountMicros: 3000000000, + currencyCode: CurrencyCode.GBP, + }, + { + amountMicros: 100000000, + currencyCode: CurrencyCode.AED, + }, + ], category: 'Basic', } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.EMAILS]: { label: 'Emails', Icon: IllustrationIconMail, - subFields: ['primaryEmail', 'additionalEmails'], - filterableSubFields: ['primaryEmail', 'additionalEmails'], - labelBySubField: { - primaryEmail: 'Primary Email', - additionalEmails: 'Additional Emails', - }, - exampleValue: { - primaryEmail: 'john@twenty.com', - additionalEmails: [ - 'tim@twenty.com', - 'timapple@twenty.com', - 'johnappletim@twenty.com', - ], - }, + subFields: [ + { + subFieldName: 'primaryEmail', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.EMAILS] + .primaryEmail, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'additionalEmails', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.EMAILS] + .additionalEmails, + isImportable: true, + isFilterable: true, + }, + ], + exampleValues: [ + { + primaryEmail: 'tim@twenty.com', + additionalEmails: [ + 'tim@twenty.com', + 'timapple@twenty.com', + 'johnappletim@twenty.com', + ], + }, + { + primaryEmail: 'jane@twenty.com', + additionalEmails: ['jane@twenty.com', 'jane.doe@twenty.com'], + }, + { + primaryEmail: 'john@twenty.com', + additionalEmails: ['john.doe@twenty.com'], + }, + ], category: 'Basic', } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.LINKS]: { label: 'Links', Icon: IllustrationIconLink, - exampleValue: { - primaryLinkUrl: 'twenty.com', - primaryLinkLabel: '', - secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }], - }, - category: 'Basic', - subFields: ['primaryLinkUrl', 'primaryLinkLabel', 'secondaryLinks'], - filterableSubFields: [ - 'primaryLinkUrl', - 'primaryLinkLabel', - 'secondaryLinks', + subFields: [ + { + subFieldName: 'primaryLinkUrl', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] + .primaryLinkUrl, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'primaryLinkLabel', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] + .primaryLinkLabel, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'secondaryLinks', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] + .secondaryLinks, + isImportable: true, + isFilterable: true, + }, ], - labelBySubField: { - primaryLinkUrl: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] - .primaryLinkUrl, - primaryLinkLabel: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] - .primaryLinkLabel, - secondaryLinks: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS] - .secondaryLinks, - }, + exampleValues: [ + { + primaryLinkUrl: 'twenty.com', + primaryLinkLabel: '', + secondaryLinks: [{ url: 'twenty.com', label: 'Twenty' }], + }, + { + primaryLinkUrl: 'github.com/twentyhq/twenty', + primaryLinkLabel: 'Twenty Repo', + secondaryLinks: [{ url: 'twenty.com', label: '' }], + }, + { + primaryLinkUrl: 'react.dev', + primaryLinkLabel: '', + secondaryLinks: [], + }, + ], + category: 'Basic', } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.PHONES]: { label: 'Phones', Icon: IllustrationIconPhone, - exampleValue: { - primaryPhoneCallingCode: '+33', - primaryPhoneCountryCode: 'FR', - primaryPhoneNumber: '789012345', - additionalPhones: [ - { number: '617272323', callingCode: '+33', countryCode: 'FR' }, - ], - }, subFields: [ - 'primaryPhoneNumber', - 'primaryPhoneCountryCode', - 'primaryPhoneCallingCode', - 'additionalPhones', + { + subFieldName: 'primaryPhoneCallingCode', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] + .primaryPhoneCallingCode, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'primaryPhoneCountryCode', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] + .primaryPhoneCountryCode, + isImportable: true, + isFilterable: false, + }, + { + subFieldName: 'primaryPhoneNumber', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] + .primaryPhoneNumber, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'additionalPhones', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] + .additionalPhones, + isImportable: true, + isFilterable: true, + }, ], - filterableSubFields: [ - 'primaryPhoneNumber', - 'primaryPhoneCallingCode', - 'additionalPhones', + exampleValues: [ + { + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneNumber: '789012345', + additionalPhones: [ + { number: '617272323', callingCode: '+33', countryCode: 'FR' }, + ], + }, + { + primaryPhoneCallingCode: '+1', + primaryPhoneCountryCode: 'US', + primaryPhoneNumber: '612345789', + additionalPhones: [ + { number: '123456789', callingCode: '+1', countryCode: 'US' }, + { number: '617272323', callingCode: '+1', countryCode: 'US' }, + ], + }, + { + primaryPhoneCallingCode: '+33', + primaryPhoneCountryCode: 'FR', + primaryPhoneNumber: '123456789', + additionalPhones: [], + }, ], - labelBySubField: { - primaryPhoneNumber: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] - .primaryPhoneNumber, - primaryPhoneCountryCode: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] - .primaryPhoneCountryCode, - primaryPhoneCallingCode: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] - .primaryPhoneCallingCode, - additionalPhones: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES] - .additionalPhones, - }, category: 'Basic', } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.FULL_NAME]: { label: 'Full Name', Icon: IllustrationIconUser, - exampleValue: { firstName: 'John', lastName: 'Doe' }, + subFields: [ + { + subFieldName: 'firstName', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME] + .firstName, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'lastName', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME] + .lastName, + isImportable: true, + isFilterable: true, + }, + ], + exampleValues: [ + { firstName: 'John', lastName: 'Doe' }, + { firstName: 'Jane', lastName: 'Doe' }, + { firstName: 'John', lastName: 'Smith' }, + ], category: 'Basic', - subFields: ['firstName', 'lastName'], - filterableSubFields: ['firstName', 'lastName'], - labelBySubField: { - firstName: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].firstName, - lastName: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].lastName, - }, } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.ADDRESS]: { label: 'Address', Icon: IllustrationIconMap, subFields: [ - 'addressStreet1', - 'addressStreet2', - 'addressCity', - 'addressState', - 'addressCountry', - 'addressPostcode', - 'addressLat', - 'addressLng', + { + subFieldName: 'addressStreet1', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressStreet1, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressStreet2', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressStreet2, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressCity', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressCity, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressState', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressState, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressCountry', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressCountry, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressPostcode', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressPostcode, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'addressLat', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressLat, + isImportable: false, + isFilterable: false, + }, + { + subFieldName: 'addressLng', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] + .addressLng, + isImportable: false, + isFilterable: false, + }, ], - filterableSubFields: [ - 'addressStreet1', - 'addressStreet2', - 'addressCity', - 'addressState', - 'addressCountry', - 'addressPostcode', + exampleValues: [ + { + addressStreet1: '456 Oak Street', + addressStreet2: '', + addressCity: 'Springfield', + addressState: 'California', + addressCountry: 'United States', + addressPostcode: '90210', + addressLat: 34.0522, + addressLng: -118.2437, + }, + { + addressStreet1: '123 Main Street', + addressStreet2: '', + addressCity: 'New York', + addressState: 'New York', + addressCountry: 'United States', + addressPostcode: '10001', + addressLat: 40.7128, + addressLng: -74.006, + }, + { + addressStreet1: '8 rue Saint-Anne', + addressStreet2: '', + addressCity: 'Paris', + addressState: 'Ile-de-France', + addressCountry: 'France', + addressPostcode: '75001', + addressLat: 40.7128, + addressLng: -74.006, + }, ], - labelBySubField: { - addressStreet1: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] - .addressStreet1, - addressStreet2: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] - .addressStreet2, - addressCity: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressCity, - addressState: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] - .addressState, - addressCountry: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] - .addressCountry, - addressPostcode: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS] - .addressPostcode, - addressLat: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressLat, - addressLng: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS].addressLng, - }, - exampleValue: { - addressStreet1: '456 Oak Street', - addressStreet2: '', - addressCity: 'Springfield', - addressState: 'California', - addressCountry: 'United States', - addressPostcode: '90210', - addressLat: 34.0522, - addressLng: -118.2437, - }, category: 'Basic', } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.ACTOR]: { label: 'Actor', Icon: IllustrationIconSetting, category: 'Basic', - subFields: ['source', 'name'], - filterableSubFields: ['source', 'name'], - labelBySubField: { - source: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].source, - name: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].name, - workspaceMemberId: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR] - .workspaceMemberId, - context: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].context, - }, - exampleValue: { - source: 'IMPORT', - name: 'name', - workspaceMemberId: 'id', - context: { provider: ConnectedAccountProvider.GOOGLE }, - }, + subFields: [ + { + subFieldName: 'source', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].source, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'name', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].name, + isImportable: true, + isFilterable: true, + }, + { + subFieldName: 'workspaceMemberId', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR] + .workspaceMemberId, + isImportable: true, + isFilterable: false, + }, + { + subFieldName: 'context', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].context, + isImportable: true, + isFilterable: false, + }, + ], + exampleValues: [ + { + source: 'IMPORT', + name: 'name', + workspaceMemberId: 'id', + context: { provider: ConnectedAccountProvider.GOOGLE }, + }, + { + source: 'MANUAL', + name: 'name', + workspaceMemberId: 'id', + context: { provider: ConnectedAccountProvider.MICROSOFT }, + }, + { + source: 'WEBHOOK', + name: 'name', + workspaceMemberId: 'id', + context: {}, + }, + ], } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.RICH_TEXT_V2]: { label: 'Rich Text', Icon: IllustrationIconText, - subFields: ['blocknote', 'markdown'], - filterableSubFields: [], - labelBySubField: { - blocknote: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2] - .blocknote, - markdown: - COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2] - .markdown, - }, - exampleValue: { - blocknote: '[{"type":"heading","content":"Hello"}]', - markdown: '# Hello', - }, category: 'Basic', + subFields: [ + { + subFieldName: 'blocknote', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2] + .blocknote, + isImportable: false, + isFilterable: false, + }, + { + subFieldName: 'markdown', + subFieldLabel: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2] + .markdown, + isImportable: false, + isFilterable: false, + }, + ], + exampleValues: [ + { + blocknote: '[{"type":"heading","content":"Hello"}]', + markdown: '# Hello', + }, + { + blocknote: '[{"type":"heading","content":"Hello World"}]', + markdown: '# Hello World', + }, + { + blocknote: '[{"type":"heading","content":"Hello Again"}]', + markdown: '# Hello Again', + }, + ], } as const satisfies SettingsCompositeFieldTypeConfig, } as const satisfies SettingsCompositeFieldTypeConfigArray; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts index b400d284666..d0f33a001eb 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs.ts @@ -15,7 +15,6 @@ import { import { DEFAULT_DATE_VALUE } from '@/settings/data-model/constants/DefaultDateValue'; import { SettingsFieldTypeCategoryType } from '@/settings/data-model/types/SettingsFieldTypeCategoryType'; import { SettingsNonCompositeFieldType } from '@/settings/data-model/types/SettingsNonCompositeFieldType'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; import { IconComponent, IllustrationIconArray, @@ -31,13 +30,14 @@ import { IllustrationIconToggle, IllustrationIconUid, } from 'twenty-ui/display'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; DEFAULT_DATE_VALUE.setFullYear(DEFAULT_DATE_VALUE.getFullYear() + 2); export type SettingsFieldTypeConfig = { label: string; Icon: IconComponent; - exampleValue?: T; + exampleValues?: [T, T, T]; category: SettingsFieldTypeCategoryType; }; @@ -52,44 +52,59 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel [FieldMetadataType.UUID]: { label: 'Unique ID', Icon: IllustrationIconUid, - exampleValue: '00000000-0000-0000-0000-000000000000', + exampleValues: [ + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + ], category: 'Advanced', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.TEXT]: { label: 'Text', Icon: IllustrationIconText, - exampleValue: + exampleValues: [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.', + 'Vestibulum magna enim, dapibus non enim in, lacinia faucibus nunc. Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.', + 'Sed interdum ante sed felis facilisis, eget ultricies neque molestie. Mauris auctor, justo eu volutpat cursus, libero erat tempus nulla, non sodales lorem lacus a est.', + ], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.NUMERIC]: { label: 'Numeric', Icon: IllustrationIconNumbers, - exampleValue: 2000, + exampleValues: [2000, 3000, 4000], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.NUMBER]: { label: 'Number', Icon: IllustrationIconNumbers, - exampleValue: 2000, + exampleValues: [2000, 3000, 4000], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.BOOLEAN]: { label: 'True/False', Icon: IllustrationIconToggle, - exampleValue: true, + exampleValues: [true, false, true], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.DATE_TIME]: { label: 'Date and Time', Icon: IllustrationIconCalendarTime, - exampleValue: DEFAULT_DATE_VALUE.toISOString(), + exampleValues: [ + DEFAULT_DATE_VALUE.toISOString(), + '2025-06-10T12:01:00.000Z', + '2018-07-14T12:02:00.000Z', + ], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.DATE]: { label: 'Date', Icon: IllustrationIconCalendarEvent, - exampleValue: DEFAULT_DATE_VALUE.toISOString(), + exampleValues: [ + DEFAULT_DATE_VALUE.toISOString(), + '2025-06-10T00:00:00.000Z', + '2018-07-14T00:00:00.000Z', + ], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.SELECT]: { @@ -110,19 +125,19 @@ export const SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS: SettingsNonCompositeFiel [FieldMetadataType.RATING]: { label: 'Rating', Icon: IllustrationIconStar, - exampleValue: 'RATING_3', + exampleValues: ['RATING_3', 'RATING_4', 'RATING_5'], category: 'Basic', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.RAW_JSON]: { label: 'JSON', Icon: IllustrationIconJson, - exampleValue: { key: 'value' }, + exampleValues: [{ key: 'value1' }, { key: 'value2', key2: 'value2' }, {}], category: 'Advanced', } as const satisfies SettingsFieldTypeConfig, [FieldMetadataType.ARRAY]: { label: 'Array', Icon: IllustrationIconArray, category: 'Advanced', - exampleValue: ['value1', 'value2'], + exampleValues: [['value1', 'value2'], ['value3'], []], } as const satisfies SettingsFieldTypeConfig, }; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts index 3e4d9e9eaec..3e09efe70e4 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/__tests__/getFieldPreviewValue.test.ts @@ -51,7 +51,7 @@ describe('getFieldPreviewValue', () => { // Then expect(result).toBe(2000); expect(result).toBe( - getSettingsFieldTypeConfig(FieldMetadataType.NUMBER).exampleValue, + getSettingsFieldTypeConfig(FieldMetadataType.NUMBER).exampleValues?.[0], ); }); diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts index ec238cb3b75..2d91dc803f4 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getAddressFieldPreviewValue.ts @@ -18,7 +18,7 @@ export const getAddressFieldPreviewValue = ({ FieldMetadataType.ADDRESS, ); - const placeholderDefaultValue = addressFieldTypeConfig.exampleValue; + const placeholderDefaultValue = addressFieldTypeConfig.exampleValues?.[0]; const addressCountry = fieldMetadataItem.defaultValue?.addressCountry && diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue.ts index 70d378044ff..1cc84e4ebdb 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getCurrencyFieldPreviewValue.ts @@ -20,7 +20,7 @@ export const getCurrencyFieldPreviewValue = ({ FieldMetadataType.CURRENCY, ); - const placeholderDefaultValue = currencyFieldTypeConfig.exampleValue; + const placeholderDefaultValue = currencyFieldTypeConfig.exampleValues?.[0]; return currencyFieldDefaultValueSchema .transform((value) => ({ diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts index 870734598e0..c89bb537a5f 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getFieldPreviewValue.ts @@ -31,10 +31,10 @@ export const getFieldPreviewValue = ({ if ( isDefined(fieldTypeConfig) && - 'exampleValue' in fieldTypeConfig && - isDefined(fieldTypeConfig.exampleValue) + 'exampleValues' in fieldTypeConfig && + isDefined(fieldTypeConfig.exampleValues?.[0]) ) { - return fieldTypeConfig.exampleValue; + return fieldTypeConfig.exampleValues?.[0]; } return null; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts index 714ccb21fe3..903f2dad91e 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue.ts @@ -38,7 +38,7 @@ export const getPhonesFieldPreviewValue = ({ FieldMetadataType.PHONES, ); - const placeholderDefaultValue = phonesFieldTypeConfig.exampleValue; + const placeholderDefaultValue = phonesFieldTypeConfig.exampleValues?.[0]; const primaryPhoneCountryCode = fieldMetadataItem.defaultValue?.primaryPhoneCountryCode && fieldMetadataItem.defaultValue.primaryPhoneCountryCode !== '' diff --git a/packages/twenty-front/src/modules/settings/data-model/utils/isValidSubFieldName.ts b/packages/twenty-front/src/modules/settings/data-model/utils/isValidSubFieldName.ts index c6ef93a2091..a8fca92438d 100644 --- a/packages/twenty-front/src/modules/settings/data-model/utils/isValidSubFieldName.ts +++ b/packages/twenty-front/src/modules/settings/data-model/utils/isValidSubFieldName.ts @@ -5,9 +5,10 @@ import { COMPOSITE_FIELD_TYPES } from '@/settings/data-model/types/CompositeFiel export const isValidSubFieldName = ( subFieldName: string, ): subFieldName is CompositeFieldSubFieldName => { - const allSubFields = COMPOSITE_FIELD_TYPES.flatMap( - (compositeFieldType) => - SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields, + const allSubFields = COMPOSITE_FIELD_TYPES.flatMap((compositeFieldType) => + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[compositeFieldType].subFields.map( + (subField) => subField.subFieldName, + ), ); return allSubFields.includes(subFieldName as any); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx index 8a41b646c71..f95e2f4dab9 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx @@ -58,8 +58,8 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({ const fieldMetadataItemSettings = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataItem.type]; - const subFieldNamesThatExistInOptions = fieldMetadataItemSettings.subFields - .filter((subFieldName) => { + const subFieldsThatExistInOptions = fieldMetadataItemSettings.subFields + .filter(({ subFieldName }) => { const optionKey = getSubFieldOptionKey(fieldMetadataItem, subFieldName); const correspondingOption = options.find( @@ -68,7 +68,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({ return isDefined(correspondingOption); }) - .filter((subFieldName) => + .filter(({ subFieldName }) => getCompositeSubFieldLabel( fieldMetadataItem.type as CompositeFieldType, subFieldName, @@ -96,7 +96,7 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({ /> - {subFieldNamesThatExistInOptions.map((subFieldName) => ( + {subFieldsThatExistInOptions.map(({ subFieldName }) => ( handleSubFieldSelect(subFieldName)} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity.ts b/packages/twenty-front/src/modules/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity.ts new file mode 100644 index 00000000000..6baa07a7315 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity.ts @@ -0,0 +1 @@ +export const SpreadsheetMaxRecordImportCapacity = 2000; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx index f3190c5cdbd..83f0777a73c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx @@ -1,6 +1,7 @@ import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { SpreadSheetImportModalWrapper } from '@/spreadsheet-import/components/SpreadSheetImportModalWrapper'; import { SPREADSHEET_IMPORT_MODAL_ID } from '@/spreadsheet-import/constants/SpreadsheetImportModalId'; +import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity'; import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer'; import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types'; @@ -19,7 +20,7 @@ export const defaultSpreadsheetImportProps: Partial< dateFormat: 'yyyy-mm-dd', // ISO 8601, parseRaw: true, selectHeader: false, - maxRecords: 2000, + maxRecords: SpreadsheetMaxRecordImportCapacity, } as const; export const SpreadsheetImport = ( diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx index da6bd3cdae6..f5cc409de43 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx @@ -3,7 +3,9 @@ import { useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { read, WorkBook } from 'xlsx-ugnis'; +import { SpreadsheetMaxRecordImportCapacity } from '@/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; +import { useDownloadFakeRecords } from '@/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords'; import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -83,6 +85,23 @@ const StyledText = styled.span` padding: 16px; `; +const StyledFooterText = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + text-align: center; + position: absolute; + bottom: ${({ theme }) => theme.spacing(4)}; + left: 50%; + transform: translateX(-50%); + width: 100%; +`; + +const StyledTextAction = styled.span` + cursor: pointer; + text-decoration: underline; +`; + type DropZoneProps = { onContinue: (data: WorkBook, file: File) => void; isLoading: boolean; @@ -95,6 +114,8 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => { const { enqueueSnackBar } = useSnackBar(); + const { downloadSample } = useDownloadFakeRecords(); + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ noClick: true, noKeyboard: true, @@ -157,6 +178,12 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => { Upload .xlsx, .xls or .csv file + + {t`Max import capacity: ${SpreadsheetMaxRecordImportCapacity} records. Otherwise, consider splitting your file or using the API.`}{' '} + + {t`Download sample file.`} + + )} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx deleted file mode 100644 index 9001a101740..00000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useMemo } from 'react'; - -import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; -import { SpreadsheetImportFields } from '@/spreadsheet-import/types'; -import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow'; - -import { generateColumns } from './columns'; - -interface ExampleTableProps { - fields: SpreadsheetImportFields; -} - -export const ExampleTable = ({ - fields, -}: ExampleTableProps) => { - const data = useMemo(() => generateExampleRow(fields), [fields]); - const columns = useMemo(() => generateColumns(fields), [fields]); - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords.ts new file mode 100644 index 00000000000..5aa53b3ab9b --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords.ts @@ -0,0 +1,138 @@ +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { spreadsheetImportFilterAvailableFieldMetadataItems } from '@/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts'; +import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; +import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs'; +import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue'; +import { saveAs } from 'file-saver'; +import { FieldMetadataType } from 'twenty-shared/types'; + +export const useDownloadFakeRecords = () => { + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + + const availableFieldMetadataItems = + spreadsheetImportFilterAvailableFieldMetadataItems( + objectMetadataItem.fields, + ); + + const buildTableWithFakeRecords = () => { + const headerRow: string[] = []; + const bodyRows: string[][] = [[], [], []]; + + availableFieldMetadataItems.forEach((field) => { + switch (field.type) { + case FieldMetadataType.RATING: + case FieldMetadataType.ARRAY: + case FieldMetadataType.RAW_JSON: + case FieldMetadataType.UUID: + case FieldMetadataType.DATE_TIME: + case FieldMetadataType.DATE: + case FieldMetadataType.BOOLEAN: + case FieldMetadataType.NUMBER: + case FieldMetadataType.TEXT: { + headerRow.push(field.label); + const exampleValues = + SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues; + + bodyRows.forEach((_, index) => { + bodyRows[index].push(exampleValues?.[index] || ''); + }); + + break; + } + case FieldMetadataType.ACTOR: + case FieldMetadataType.EMAILS: + case FieldMetadataType.CURRENCY: + case FieldMetadataType.FULL_NAME: + case FieldMetadataType.LINKS: + case FieldMetadataType.PHONES: + case FieldMetadataType.ADDRESS: { + const compositeFieldSettings = + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type]; + + const subFields = compositeFieldSettings.subFields.filter( + (subField) => subField.isImportable, + ); + + const exampleValues = + SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].exampleValues; + + headerRow.push( + ...subFields.map( + ({ subFieldLabel }) => `${field.label} / ${subFieldLabel}`, + ), + ); + + bodyRows.forEach((_, index) => { + subFields.forEach(({ subFieldName }) => { + bodyRows[index].push( + exampleValues?.[index]?.[ + subFieldName as keyof (typeof exampleValues)[typeof index] + ] || '', + ); + }); + }); + + break; + } + + case FieldMetadataType.RELATION: { + headerRow.push(`${field.label} (ID)`); + + const exampleValues = + SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[FieldMetadataType.UUID] + .exampleValues; + + bodyRows.forEach((_, index) => { + bodyRows[index].push(exampleValues?.[index] || ''); + }); + + break; + } + + case FieldMetadataType.MULTI_SELECT: + headerRow.push(field.label); + + bodyRows.forEach((_, index) => { + bodyRows[index].push( + JSON.stringify( + field?.options + ?.map((option) => option?.value) + .slice(0, index) || [], + ), + ); + }); + + break; + + case FieldMetadataType.SELECT: + headerRow.push(field.label); + + bodyRows.forEach((_, index) => { + bodyRows[index].push(field?.options?.[index]?.value || ''); + }); + + break; + } + }); + + return { headerRow, bodyRows }; + }; + + const formatToCsvContent = (rows: string[][]) => { + const escapedRows = rows.map((row) => { + return row.map((value) => escapeCSVValue(value)); + }); + + const csvContent = [...escapedRows.map((row) => row.join(','))].join('\n'); + return [csvContent]; + }; + + const downloadSample = () => { + const { headerRow, bodyRows } = buildTableWithFakeRecords(); + const csvContent = formatToCsvContent([headerRow, ...bodyRows]); + const blob = new Blob(csvContent, { type: 'text/csv' }); + saveAs(blob, `${objectMetadataItem.labelPlural.toLowerCase()}-sample.csv`); + }; + + return { downloadSample }; +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx index d2a499ba0b6..7d74d6021e4 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx @@ -4,7 +4,9 @@ import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/Componen import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { stepBarInternalState } from '@/ui/navigation/step-bar/states/stepBarInternalState'; +import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SpreadsheetImportStepperContainer } from '../SpreadsheetImportStepperContainer'; const meta: Meta = { @@ -14,6 +16,8 @@ const meta: Meta = { ComponentWithRecoilScopeDecorator, SnackBarDecorator, I18nFrontDecorator, + ObjectMetadataItemsDecorator, + ContextStoreDecorator, ], parameters: { initialRecoilState: { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx index 784d9c17358..9747cf9c7a7 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx @@ -8,7 +8,9 @@ import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/Spre import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; import { RecoilRoot } from 'recoil'; +import { ContextStoreDecorator } from '~/testing/decorators/ContextStoreDecorator'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; const meta: Meta = { @@ -18,6 +20,8 @@ const meta: Meta = { layout: 'fullscreen', }, decorators: [ + ObjectMetadataItemsDecorator, + ContextStoreDecorator, (Story) => ( { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/escapeCSVValue.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/escapeCSVValue.test.ts new file mode 100644 index 00000000000..f8899e27202 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/escapeCSVValue.test.ts @@ -0,0 +1,24 @@ +import { escapeCSVValue } from '@/spreadsheet-import/utils/escapeCSVValue'; + +describe('escapeCSVValue', () => { + it('should escape values with commas, quotes, newlines and carriage returns', () => { + expect(escapeCSVValue('test,test')).toBe('"test,test"'); + }); + + it('should escape array or JSON values', () => { + expect(escapeCSVValue(['test', 'test'])).toBe('"[""test"",""test""]"'); + expect(escapeCSVValue({ test: 'test' })).toBe('"{""test"":""test""}"'); + }); + + it('should escape null values', () => { + expect(escapeCSVValue(null)).toBe(''); + }); + + it('should escape simple string value', () => { + expect(escapeCSVValue('test')).toBe('test'); + }); + + it('should escape simple number value', () => { + expect(escapeCSVValue(1)).toBe('1'); + }); +}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts deleted file mode 100644 index 364ecddd072..00000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { SpreadsheetImportField } from '@/spreadsheet-import/types'; -import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow'; -import { FieldMetadataType } from 'twenty-shared/types'; - -describe('generateExampleRow', () => { - const defaultField: SpreadsheetImportField<'defaultField'> = { - key: 'defaultField', - Icon: null, - label: 'label', - fieldType: { - type: 'input', - }, - fieldMetadataType: FieldMetadataType.TEXT, - }; - - it('should generate an example row from input field type', () => { - const fields: SpreadsheetImportField<'defaultField'>[] = [defaultField]; - - const result = generateExampleRow(fields); - - expect(result).toStrictEqual([{ defaultField: 'Text' }]); - }); - - it('should generate an example row from checkbox field type', () => { - const fields: SpreadsheetImportField<'defaultField'>[] = [ - { - ...defaultField, - fieldType: { type: 'checkbox' }, - fieldMetadataType: FieldMetadataType.BOOLEAN, - }, - ]; - - const result = generateExampleRow(fields); - - expect(result).toStrictEqual([{ defaultField: 'Boolean' }]); - }); - - it('should generate an example row from select field type', () => { - const fields: SpreadsheetImportField<'defaultField'>[] = [ - { - ...defaultField, - fieldType: { type: 'select', options: [] }, - fieldMetadataType: FieldMetadataType.SELECT, - }, - ]; - - const result = generateExampleRow(fields); - - expect(result).toStrictEqual([{ defaultField: 'Options' }]); - }); - - it('should generate an example row with provided example values for fields', () => { - const fields: SpreadsheetImportField<'defaultField'>[] = [ - { - ...defaultField, - example: 'Example', - fieldMetadataType: FieldMetadataType.TEXT, - }, - ]; - - const result = generateExampleRow(fields); - - expect(result).toStrictEqual([{ defaultField: 'Example' }]); - }); -}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/escapeCSVValue.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/escapeCSVValue.ts new file mode 100644 index 00000000000..554440ee849 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/escapeCSVValue.ts @@ -0,0 +1,18 @@ +import { isString } from '@sniptt/guards'; + +export const escapeCSVValue = (value: any) => { + if (value == null) return ''; + + const stringValue = isString(value) ? value : JSON.stringify(value); + + if ( + stringValue.includes(',') || + stringValue.includes('"') || + stringValue.includes('\n') || + stringValue.includes('\r') + ) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + + return stringValue; +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/generateExampleRow.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/generateExampleRow.ts deleted file mode 100644 index 493e7c4471c..00000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/generateExampleRow.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - SpreadsheetImportField, - SpreadsheetImportFields, -} from '@/spreadsheet-import/types'; - -const titleMap: Record< - SpreadsheetImportField['fieldType']['type'], - string -> = { - checkbox: 'Boolean', - select: 'Options', - multiSelect: 'Options', - input: 'Text', -}; - -export const generateExampleRow = ( - fields: SpreadsheetImportFields, -) => [ - fields.reduce( - (acc, field) => { - acc[field.key as T] = field.example || titleMap[field.fieldType.type]; - return acc; - }, - {} as Record, - ), -];