download record sample - Import (#12489)

<img width="400" alt="Screenshot 2025-06-10 at 18 14 17"
src="https://github.com/user-attachments/assets/05591b46-c36d-45c6-a236-3469c29d7420"
/>


closes https://github.com/twentyhq/core-team-issues/issues/915

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2025-06-16 16:01:27 +02:00
committed by GitHub
parent 79b8c4660c
commit c16ba6a7d7
32 changed files with 753 additions and 481 deletions

View File

@ -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) &&

View File

@ -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 || ''
);
};

View File

@ -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
);
};

View File

@ -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;

View File

@ -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<FieldFullNameValue>,
[FieldMetadataType.CURRENCY]: {
currencyCodeLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField
.currencyCode,
amountMicrosLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.CURRENCY.labelBySubField
.amountMicros,
} satisfies CompositeFieldLabels<FieldCurrencyValue>,
[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<FieldAddressValue>,
'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<FieldLinksValue>,
[FieldMetadataType.EMAILS]: {
primaryEmailLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField.primaryEmail,
additionalEmailsLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.EMAILS.labelBySubField
.additionalEmails,
} satisfies CompositeFieldLabels<FieldEmailsValue>,
[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<FieldPhonesValue>,
[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<FieldRichTextV2Value>,
[FieldMetadataType.ACTOR]: {
sourceLabel:
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.source,
nameLabel: SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS.ACTOR.labelBySubField.name,
} satisfies Partial<CompositeFieldLabels<FieldActorValue>>,
};

View File

@ -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,
),
}),
);

View File

@ -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<string, any> =
buildRecordFromImportedStructuredRow({
importedStructuredRow: record,
fields: availableFieldMetadataItems,
fields: availableFieldMetadataItemsToImport,
});
return fieldMapping;
@ -70,8 +69,8 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
});
}
},
fields: availableFields,
availableFieldMetadataItems,
fields: availableFieldsForMatching,
availableFieldMetadataItems: availableFieldMetadataItemsToImport,
});
};

View File

@ -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;
}

View File

@ -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',

View File

@ -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})`;

View File

@ -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),
);
};