From c16ba6a7d7b2811b3f317dbe77706198ab0be8c7 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Mon, 16 Jun 2025 16:01:27 +0200 Subject: [PATCH] download record sample - Import (#12489) Screenshot 2025-06-10 at 18 14 17 closes https://github.com/twentyhq/core-team-issues/issues/915 --------- Co-authored-by: Charles Bochet --- .../AdvancedFilterSubFieldSelectMenu.tsx | 6 +- .../utils/getCompositeSubFieldLabel.ts | 9 +- .../utils/isExpectedSubFieldName.ts | 9 +- .../export/hooks/useExportRecords.ts | 7 +- .../constants/CompositeFieldImportLabels.ts | 97 ---- .../hooks/useBuildAvailableFieldsForImport.ts | 9 +- ...penObjectRecordsSpreadsheetImportDialog.ts | 35 +- .../buildRecordFromImportedStructuredRow.ts | 54 +- ...etSpreadSheetFieldValidationDefinitions.ts | 14 +- .../utils/getSubFieldOptionKey.ts | 14 +- ...ortFilterAvailableFieldMetadataItems.ts.ts | 23 + .../data-model/constants/AllSubFields.ts | 4 +- .../SettingsCompositeFieldTypeConfigs.ts | 549 ++++++++++++------ .../SettingsNonCompositeFieldTypeConfigs.ts | 39 +- .../__tests__/getFieldPreviewValue.test.ts | 2 +- .../utils/getAddressFieldPreviewValue.ts | 2 +- .../utils/getCurrencyFieldPreviewValue.ts | 2 +- .../preview/utils/getFieldPreviewValue.ts | 6 +- .../utils/getPhonesFieldPreviewValue.ts | 2 +- .../data-model/utils/isValidSubFieldName.ts | 7 +- ...umnSelectSubFieldSelectDropdownContent.tsx | 8 +- .../SpreadsheetMaxRecordImportCapacity.ts | 1 + .../provider/components/SpreadsheetImport.tsx | 3 +- .../UploadStep/components/DropZone.tsx | 27 + .../UploadStep/components/ExampleTable.tsx | 26 - .../hooks/useDownloadFakeRecords.ts | 138 +++++ .../components/__stories__/Steps.stories.tsx | 4 + .../components/__stories__/Upload.stories.tsx | 4 + .../utils/__tests__/escapeCSVValue.test.ts | 24 + .../__tests__/generateExampleRow.test.ts | 65 --- .../utils/escapeCSVValue.ts | 18 + .../utils/generateExampleRow.ts | 26 - 32 files changed, 753 insertions(+), 481 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/spreadsheet-import/constants/CompositeFieldImportLabels.ts create mode 100644 packages/twenty-front/src/modules/object-record/spreadsheet-import/utils/spreadsheetImportFilterAvailableFieldMetadataItems.ts.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/constants/SpreadsheetMaxRecordImportCapacity.ts delete mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/ExampleTable.tsx create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/hooks/useDownloadFakeRecords.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/escapeCSVValue.test.ts delete mode 100644 packages/twenty-front/src/modules/spreadsheet-import/utils/__tests__/generateExampleRow.test.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/utils/escapeCSVValue.ts delete mode 100644 packages/twenty-front/src/modules/spreadsheet-import/utils/generateExampleRow.ts 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 e693d1606..3e8001950 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 36c6f04a4..fa5fc9066 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 65cf31e04..8074ec708 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 6b5838881..3db1e131c 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 2db9a5443..000000000 --- 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 607c167ff..135176e76 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 c1db47fc2..f48cfd232 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 05647babf..4f93d5f4b 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 fba6efedd..b3aadea9c 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 1af94727d..b9f0d78d5 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 000000000..f91bd066a --- /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 307d935df..f92b4f652 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 5b92fa58d..f525810c4 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 b400d2846..d0f33a001 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 3e4d9e9ea..3e09efe70 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 ec238cb3b..2d91dc803 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 70d378044..1cc84e4eb 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 870734598..c89bb537a 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 714ccb21f..903f2dad9 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 c6ef93a20..a8fca9243 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 8a41b646c..f95e2f4da 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 000000000..6baa07a73 --- /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 f3190c5cd..83f0777a7 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 da6bd3cda..f5cc409de 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 9001a1017..000000000 --- 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 000000000..5aa53b3ab --- /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 d2a499ba0..7d74d6021 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 784d9c173..9747cf9c7 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 000000000..f8899e272 --- /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 364ecddd0..000000000 --- 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 000000000..554440ee8 --- /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 493e7c447..000000000 --- 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, - ), -];