From 713d3defefd62eb8774d5f290361fd74fcafdb10 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:07:51 +0200 Subject: [PATCH] Import - Upsert on composite fields (#12615) To test : - Import a record with Id column (for upsert-ing) + some subfields in each composite fields. Check that only matched subfields are updated (Main issue) - Import a record with a multi-select field - Check it works + Match multi-select field on a non multi-select column, check it does not work. (Specific bug fixed in second commit is : undefined value in multi select column (corresponding to no item selected) caused error in multi-select parsing). closes https://github.com/twentyhq/core-team-issues/issues/990 --- .../buildRecordFromImportedStructuredRow.ts | 332 ++++++++---------- ...ColumnSelectFieldSelectDropdownContent.tsx | 9 - .../components/MatchColumnToFieldSelect.tsx | 1 - .../MatchColumnsStep/MatchColumnsStep.tsx | 17 +- .../spreadsheet-import/utils/setColumn.ts | 2 + 5 files changed, 144 insertions(+), 217 deletions(-) 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 4f93d5f4b..7b0583375 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 @@ -1,12 +1,5 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { - FieldActorForInputValue, - FieldAddressValue, - FieldEmailsValue, - FieldLinksValue, - FieldPhonesValue, - FieldRichTextV2Value, -} from '@/object-record/record-field/types/FieldMetadata'; +import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata'; import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel'; import { ImportedStructuredRow } from '@/spreadsheet-import/types'; import { isNonEmptyString } from '@sniptt/guards'; @@ -15,12 +8,42 @@ import { z } from 'zod'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { castToString } from '~/utils/castToString'; import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; +import { isEmptyObject } from '~/utils/isEmptyObject'; type BuildRecordFromImportedStructuredRowArgs = { importedStructuredRow: ImportedStructuredRow; fields: FieldMetadataItem[]; }; +const buildCompositeFieldRecord = ( + fieldName: string, + importedStructuredRow: ImportedStructuredRow, + compositeFieldConfig: Record< + string, + { + labelKey: string; + transform?: (value: any) => any; + } + >, +): Record | undefined => { + const compositeFieldRecord = Object.entries(compositeFieldConfig).reduce( + ( + acc, + [compositeFieldKey, { labelKey: compositeFieldLabelKey, transform }], + ) => { + const value = + importedStructuredRow[`${compositeFieldLabelKey} (${fieldName})`]; + + return isDefined(value) + ? { ...acc, [compositeFieldKey]: transform?.(value) || value } + : acc; + }, + {}, + ); + + return isEmptyObject(compositeFieldRecord) ? undefined : compositeFieldRecord; +}; + export const buildRecordFromImportedStructuredRow = ({ fields, importedStructuredRow, @@ -115,10 +138,115 @@ export const buildRecordFromImportedStructuredRow = ({ RICH_TEXT_V2: { blocknote: blocknoteLabel, markdown: markdownLabel }, } = COMPOSITE_FIELD_SUB_FIELD_LABELS; + const COMPOSITE_FIELD_CONFIGS = { + [FieldMetadataType.CURRENCY]: { + amountMicros: { + labelKey: amountMicrosLabel, + transform: (value: any) => + convertCurrencyAmountToCurrencyMicros(Number(value)), + }, + currencyCode: { labelKey: currencyCodeLabel }, + }, + + [FieldMetadataType.ADDRESS]: { + addressStreet1: { + labelKey: addressStreet1Label, + transform: castToString, + }, + addressStreet2: { + labelKey: addressStreet2Label, + transform: castToString, + }, + addressCity: { labelKey: addressCityLabel, transform: castToString }, + addressPostcode: { + labelKey: addressPostcodeLabel, + transform: castToString, + }, + addressState: { labelKey: addressStateLabel, transform: castToString }, + addressCountry: { + labelKey: addressCountryLabel, + transform: castToString, + }, + }, + + [FieldMetadataType.LINKS]: { + primaryLinkLabel: { + labelKey: primaryLinkLabelLabel, + transform: castToString, + }, + primaryLinkUrl: { + labelKey: primaryLinkUrlLabel, + transform: castToString, + }, + secondaryLinks: { + labelKey: secondaryLinksLabel, + transform: linkArrayJSONSchema.parse, + }, + }, + + [FieldMetadataType.PHONES]: { + primaryPhoneCountryCode: { + labelKey: primaryPhoneCountryCodeLabel, + transform: castToString, + }, + primaryPhoneNumber: { + labelKey: primaryPhoneNumberLabel, + transform: castToString, + }, + primaryPhoneCallingCode: { + labelKey: primaryPhoneCallingCodeLabel, + transform: castToString, + }, + additionalPhones: { + labelKey: additionalPhonesLabel, + transform: phoneArrayJSONSchema.parse, + }, + }, + + [FieldMetadataType.RICH_TEXT_V2]: { + blocknote: { labelKey: blocknoteLabel, transform: castToString }, + markdown: { labelKey: markdownLabel, transform: castToString }, + }, + + [FieldMetadataType.EMAILS]: { + primaryEmail: { labelKey: primaryEmailLabel, transform: castToString }, + additionalEmails: { + labelKey: additionalEmailsLabel, + transform: stringArrayJSONSchema.parse, + }, + }, + + [FieldMetadataType.FULL_NAME]: { + firstName: { labelKey: firstNameLabel }, + lastName: { labelKey: lastNameLabel }, + }, + [FieldMetadataType.ACTOR]: { + source: { labelKey: 'source', transform: () => 'IMPORT' }, + context: { labelKey: 'context', transform: () => ({}) }, + }, + }; + for (const field of fields) { const importedFieldValue = importedStructuredRow[field.name]; switch (field.type) { + case FieldMetadataType.CURRENCY: + case FieldMetadataType.ADDRESS: + case FieldMetadataType.LINKS: + case FieldMetadataType.PHONES: + case FieldMetadataType.RICH_TEXT_V2: + case FieldMetadataType.EMAILS: + case FieldMetadataType.FULL_NAME: { + const compositeData = buildCompositeFieldRecord( + field.name, + importedStructuredRow, + COMPOSITE_FIELD_CONFIGS[field.type], + ); + if (isDefined(compositeData)) { + recordToBuild[field.name] = compositeData; + } + break; + } case FieldMetadataType.BOOLEAN: recordToBuild[field.name] = importedFieldValue === 'true' || importedFieldValue === true; @@ -127,193 +255,13 @@ export const buildRecordFromImportedStructuredRow = ({ case FieldMetadataType.NUMERIC: recordToBuild[field.name] = Number(importedFieldValue); break; - case FieldMetadataType.CURRENCY: - if ( - isDefined( - importedStructuredRow[`${amountMicrosLabel} (${field.name})`], - ) || - isDefined( - importedStructuredRow[`${currencyCodeLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - amountMicros: convertCurrencyAmountToCurrencyMicros( - Number( - importedStructuredRow[`${amountMicrosLabel} (${field.name})`], - ), - ), - currencyCode: - importedStructuredRow[`${currencyCodeLabel} (${field.name})`] || - 'USD', - }; - } - break; - case FieldMetadataType.ADDRESS: { - if ( - isDefined( - importedStructuredRow[`${addressStreet1Label} (${field.name})`] || - importedStructuredRow[`${addressStreet2Label} (${field.name})`] || - importedStructuredRow[`${addressCityLabel} (${field.name})`] || - importedStructuredRow[ - `${addressPostcodeLabel} (${field.name})` - ] || - importedStructuredRow[`${addressStateLabel} (${field.name})`] || - importedStructuredRow[`${addressCountryLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - addressStreet1: castToString( - importedStructuredRow[`${addressStreet1Label} (${field.name})`], - ), - addressStreet2: castToString( - importedStructuredRow[`${addressStreet2Label} (${field.name})`], - ), - addressCity: castToString( - importedStructuredRow[`${addressCityLabel} (${field.name})`], - ), - addressPostcode: castToString( - importedStructuredRow[`${addressPostcodeLabel} (${field.name})`], - ), - addressState: castToString( - importedStructuredRow[`${addressStateLabel} (${field.name})`], - ), - addressCountry: castToString( - importedStructuredRow[`${addressCountryLabel} (${field.name})`], - ), - } satisfies Partial; - } - break; - } - case FieldMetadataType.LINKS: { - if ( - isDefined( - importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`] || - importedStructuredRow[ - `${primaryLinkLabelLabel} (${field.name})` - ] || - importedStructuredRow[`${secondaryLinksLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - primaryLinkLabel: castToString( - importedStructuredRow[`${primaryLinkLabelLabel} (${field.name})`], - ), - primaryLinkUrl: castToString( - importedStructuredRow[`${primaryLinkUrlLabel} (${field.name})`], - ), - secondaryLinks: linkArrayJSONSchema.parse( - importedStructuredRow[`${secondaryLinksLabel} (${field.name})`], - ), - } satisfies FieldLinksValue; - } - break; - } - case FieldMetadataType.PHONES: { - if ( - isDefined( - importedStructuredRow[ - `${primaryPhoneCountryCodeLabel} (${field.name})` - ] || - importedStructuredRow[ - `${primaryPhoneNumberLabel} (${field.name})` - ] || - importedStructuredRow[ - `${primaryPhoneCallingCodeLabel} (${field.name})` - ] || - importedStructuredRow[`${additionalPhonesLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - primaryPhoneCountryCode: castToString( - importedStructuredRow[ - `${primaryPhoneCountryCodeLabel} (${field.name})` - ], - ), - primaryPhoneNumber: castToString( - importedStructuredRow[ - `${primaryPhoneNumberLabel} (${field.name})` - ], - ), - primaryPhoneCallingCode: castToString( - importedStructuredRow[ - `${primaryPhoneCallingCodeLabel} (${field.name})` - ], - ), - additionalPhones: phoneArrayJSONSchema.parse( - importedStructuredRow[`${additionalPhonesLabel} (${field.name})`], - ), - } satisfies FieldPhonesValue; - } - break; - } - case FieldMetadataType.RICH_TEXT_V2: { - if ( - isDefined( - importedStructuredRow[`${blocknoteLabel} (${field.name})`] || - importedStructuredRow[`${markdownLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - blocknote: castToString( - importedStructuredRow[`${blocknoteLabel} (${field.name})`], - ), - markdown: castToString( - importedStructuredRow[`${markdownLabel} (${field.name})`], - ), - } satisfies FieldRichTextV2Value; - } - break; - } - case FieldMetadataType.EMAILS: { - if ( - isDefined( - importedStructuredRow[`${primaryEmailLabel} (${field.name})`], - ) || - isDefined( - importedStructuredRow[`${additionalEmailsLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - primaryEmail: castToString( - importedStructuredRow[`${primaryEmailLabel} (${field.name})`], - ), - additionalEmails: stringArrayJSONSchema.parse( - importedStructuredRow[`${additionalEmailsLabel} (${field.name})`], - ), - } satisfies FieldEmailsValue; - } - break; - } - case FieldMetadataType.UUID: - if ( - isDefined(importedFieldValue) && - isNonEmptyString(importedFieldValue) - ) { - recordToBuild[field.name] = importedFieldValue; - } - break; case FieldMetadataType.RELATION: if ( isDefined(importedFieldValue) && isNonEmptyString(importedFieldValue) - ) { + ) recordToBuild[field.name + 'Id'] = importedFieldValue; - } - break; - case FieldMetadataType.FULL_NAME: - if ( - isDefined( - importedStructuredRow[`${firstNameLabel} (${field.name})`] ?? - importedStructuredRow[`${lastNameLabel} (${field.name})`], - ) - ) { - recordToBuild[field.name] = { - firstName: - importedStructuredRow[`${firstNameLabel} (${field.name})`] ?? '', - lastName: - importedStructuredRow[`${lastNameLabel} (${field.name})`] ?? '', - }; - } + break; case FieldMetadataType.ACTOR: recordToBuild[field.name] = { @@ -338,7 +286,9 @@ export const buildRecordFromImportedStructuredRow = ({ break; } default: - recordToBuild[field.name] = importedFieldValue; + if (isDefined(importedFieldValue)) { + recordToBuild[field.name] = importedFieldValue; + } break; } } diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx index 44268e052..14becaadd 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx @@ -30,7 +30,6 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ onSelectSuggestedOption, onCancelSelect, onDoNotImportSelect, - options, suggestedOptions, }: { selectedValue: SelectOption | undefined; @@ -40,9 +39,6 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ onSelectSuggestedOption: (selectedSuggestedOption: SelectOption) => void; onCancelSelect: () => void; onDoNotImportSelect: () => void; - options: readonly ReadonlyDeep< - SelectOption & { fieldMetadataTypeLabel?: string } - >[]; suggestedOptions: readonly ReadonlyDeep< SelectOption & { fieldMetadataTypeLabel?: string } >[]; @@ -121,7 +117,6 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ key={option.value} selected={selectedValue?.value === option.value} onClick={() => handleSuggestedOptionClick(option)} - disabled={option.disabled} LeftIcon={option.Icon} text={option.label} contextualText={option.fieldMetadataTypeLabel} @@ -140,10 +135,6 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ key={field.id} selected={selectedValue?.value === field.name} onClick={() => handleFieldClick(field)} - disabled={ - options.find((option) => option.value === field.name) - ?.disabled && selectedValue?.value !== field.name - } LeftIcon={getIcon(field.icon)} text={field.label} contextualText={getFieldMetadataTypeLabel(field.type)} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx index 4cc66d240..c29bbbf69 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx @@ -150,7 +150,6 @@ export const MatchColumnToFieldSelect = ({ onSelectSuggestedOption={handleSelectSuggestedOption} onCancelSelect={handleCancelSelectClick} onDoNotImportSelect={handleDoNotImportSelect} - options={options} suggestedOptions={suggestedOptions} /> ) diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx index d8db1b066..12bbcdf39 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx @@ -10,8 +10,6 @@ import { setColumn } from '@/spreadsheet-import/utils/setColumn'; import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn'; import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; -import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Modal } from '@/ui/layout/modal/components/Modal'; @@ -77,7 +75,6 @@ export const MatchColumnsStep = ({ onError, }: MatchColumnsStepProps) => { const { enqueueDialog } = useDialogManager(); - const { enqueueSnackBar } = useSnackBar(); const dataExample = data.slice(0, 2); const { fields } = useSpreadsheetImportInternal(); const [isLoading, setIsLoading] = useState(false); @@ -131,10 +128,6 @@ export const MatchColumnsStep = ({ if (columnIndex === index) { return setColumn(column, field, data); } else if (index === existingFieldIndex) { - enqueueSnackBar('Another column unselected', { - detailedMessage: 'Columns cannot duplicate', - variant: SnackBarVariant.Error, - }); return setColumn(column); } else { return column; @@ -143,15 +136,7 @@ export const MatchColumnsStep = ({ ); } }, - [ - columns, - onRevertIgnore, - onIgnore, - fields, - setColumns, - data, - enqueueSnackBar, - ], + [columns, onRevertIgnore, onIgnore, fields, setColumns, data], ); const handleContinue = useCallback( diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts index 10ed524ca..c2cfd802c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts @@ -5,6 +5,7 @@ import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn' import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions'; import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; import { z } from 'zod'; import { uniqueEntries } from './uniqueEntries'; @@ -53,6 +54,7 @@ export const setColumn = ( data ?.flatMap((row) => { const value = row[oldColumn.index]; + if (!isDefined(value)) return []; const options = JSON.parse(z.string().parse(value)); return z.array(z.string()).parse(options); })