Import v2 - add label for subfield in csv export (#12355)

To test : Export companies record - "Domain Name PrimaryLinkUrl" >>
"Domain Name / Link URL"

closes https://github.com/twentyhq/core-team-issues/issues/907
This commit is contained in:
Etienne
2025-06-02 17:21:44 +02:00
committed by GitHub
parent dc205370df
commit e71aef5a3a
4 changed files with 151 additions and 54 deletions

View File

@ -1,7 +1,10 @@
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { RelationDefinitionType } from '~/generated-metadata/graphql'; import {
FieldMetadataType,
RelationDefinitionType,
} from '~/generated-metadata/graphql';
import { displayedExportProgress, generateCsv } from '../useExportRecords'; import { displayedExportProgress, generateCsv } from '../useExportRecords';
jest.useFakeTimers(); jest.useFakeTimers();
@ -11,7 +14,11 @@ describe('generateCsv', () => {
const columns = [ const columns = [
{ label: 'Foo', metadata: { fieldName: 'foo' } }, { label: 'Foo', metadata: { fieldName: 'foo' } },
{ label: 'Empty', metadata: { fieldName: 'empty' } }, { label: 'Empty', metadata: { fieldName: 'empty' } },
{ label: 'Nested', metadata: { fieldName: 'nested' } }, {
label: 'Nested link field',
type: FieldMetadataType.LINKS,
metadata: { fieldName: 'nestedLinkField' },
},
{ {
label: 'Relation', label: 'Relation',
metadata: { metadata: {
@ -26,13 +33,21 @@ describe('generateCsv', () => {
bar: 'another field', bar: 'another field',
empty: null, empty: null,
foo: 'some field', foo: 'some field',
nested: { __typename: 'type', foo: 'foo', nested: 'nested' }, nestedLinkField: {
__typename: 'Links',
primaryLinkUrl: 'https://www.test.com',
secondaryLinks: [
{ label: 'secondary link 1', url: 'https://www.test.com' },
{ label: 'secondary link 2', url: 'https://www.test.com' },
],
},
relation: 'a relation', relation: 'a relation',
}, },
]; ];
const csv = generateCsv({ columns, rows }); const csv = generateCsv({ columns, rows });
expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation expect(csv)
1,some field,,foo,nested,a relation`); .toEqual(`Id,Foo,Empty,Nested link field / Link URL,Nested link field / Secondary Links,Relation
1,some field,,https://www.test.com,"[{""label"":""secondary link 1"",""url"":""https://www.test.com""},{""label"":""secondary link 2"",""url"":""https://www.test.com""}]",a relation`);
}); });
}); });

View File

@ -1,6 +1,7 @@
import { json2csv } from 'json-2-csv'; import { json2csv } from 'json-2-csv';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize'; import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize';
import { useExportProcessRecordsForCSV } from '@/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV'; import { useExportProcessRecordsForCSV } from '@/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
@ -10,16 +11,17 @@ import {
} from '@/object-record/record-index/export/hooks/useExportFetchRecords'; } from '@/object-record/record-index/export/hooks/useExportFetchRecords';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { isDefined } from 'twenty-shared/utils';
import { RelationDefinitionType } from '~/generated-metadata/graphql'; import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { isDefined } from 'twenty-shared/utils';
type GenerateExportOptions = { type GenerateExportOptions = {
columns: ColumnDefinition<FieldMetadata>[]; columns: ColumnDefinition<FieldMetadata>[];
rows: object[]; rows: Record<string, any>[];
}; };
type GenerateExport = (data: GenerateExportOptions) => string; type GenerateExport = (data: GenerateExportOptions) => string;
@ -62,31 +64,20 @@ export const generateCsv: GenerateExport = ({
.join(' '), .join(' '),
}; };
const fieldsWithSubFields = rows.find((row) => { const columnType = col.type;
const fieldValue = (row as any)[column.field]; if (!isCompositeFieldType(columnType)) return [column];
const hasSubFields = const nestedFieldsWithoutTypename = Object.keys(rows[0][column.field])
fieldValue && .filter((key) => key !== '__typename')
typeof fieldValue === 'object' && .map((key) => {
!Array.isArray(fieldValue); const subFieldLabel = COMPOSITE_FIELD_SUB_FIELD_LABELS[columnType][key];
return {
return hasSubFields;
});
if (isDefined(fieldsWithSubFields)) {
const nestedFieldsWithoutTypename = Object.keys(
(fieldsWithSubFields as any)[column.field],
)
.filter((key) => key !== '__typename')
.map((key) => ({
field: `${column.field}.${key}`, field: `${column.field}.${key}`,
title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`, title: `${column.title} / ${subFieldLabel}`,
})); };
});
return nestedFieldsWithoutTypename; return nestedFieldsWithoutTypename;
}
return [column];
}); });
return json2csv(rows, { return json2csv(rows, {

View File

@ -0,0 +1,50 @@
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
export const COMPOSITE_FIELD_SUB_FIELD_LABELS: {
[key in CompositeFieldType]: Record<string, string>;
} = {
[FieldMetadataType.CURRENCY]: {
amountMicros: 'Amount',
currencyCode: 'Currency',
},
[FieldMetadataType.EMAILS]: {
primaryEmail: 'Primary Email',
additionalEmails: 'Additional Emails',
},
[FieldMetadataType.LINKS]: {
primaryLinkLabel: 'Link Label',
primaryLinkUrl: 'Link URL',
secondaryLinks: 'Secondary Links',
},
[FieldMetadataType.PHONES]: {
primaryPhoneNumber: 'Primary Phone Number',
primaryPhoneCountryCode: 'Primary Phone Country Code',
primaryPhoneCallingCode: 'Primary Phone Calling Code',
additionalPhones: 'Additional Phones',
},
[FieldMetadataType.FULL_NAME]: {
firstName: 'First Name',
lastName: 'Last Name',
},
[FieldMetadataType.ADDRESS]: {
addressStreet1: 'Address 1',
addressStreet2: 'Address 2',
addressCity: 'City',
addressState: 'State',
addressCountry: 'Country',
addressPostcode: 'Post Code',
addressLat: 'Latitude',
addressLng: 'Longitude',
},
[FieldMetadataType.ACTOR]: {
source: 'Source',
name: 'Name',
workspaceMemberId: 'Workspace Member ID',
context: 'Context',
},
[FieldMetadataType.RICH_TEXT_V2]: {
blocknote: 'BlockNote',
markdown: 'Markdown',
},
};

View File

@ -9,6 +9,7 @@ import {
FieldPhonesValue, FieldPhonesValue,
FieldRichTextV2Value, FieldRichTextV2Value,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
import { COMPOSITE_FIELD_SUB_FIELD_LABELS } from '@/settings/data-model/constants/CompositeFieldSubFieldLabel';
import { SettingsFieldTypeConfig } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs'; import { SettingsFieldTypeConfig } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType'; import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { ConnectedAccountProvider } from 'twenty-shared/types'; import { ConnectedAccountProvider } from 'twenty-shared/types';
@ -43,8 +44,12 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
subFields: ['amountMicros', 'currencyCode'], subFields: ['amountMicros', 'currencyCode'],
filterableSubFields: ['amountMicros', 'currencyCode'], filterableSubFields: ['amountMicros', 'currencyCode'],
labelBySubField: { labelBySubField: {
amountMicros: 'Amount', amountMicros:
currencyCode: 'Currency', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.amountMicros,
currencyCode:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY]
.currencyCode,
}, },
exampleValue: { exampleValue: {
amountMicros: 2000000000, amountMicros: 2000000000,
@ -87,9 +92,15 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
'secondaryLinks', 'secondaryLinks',
], ],
labelBySubField: { labelBySubField: {
primaryLinkUrl: 'Link URL', primaryLinkUrl:
primaryLinkLabel: 'Link Label', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
secondaryLinks: 'Secondary Links', .primaryLinkUrl,
primaryLinkLabel:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.primaryLinkLabel,
secondaryLinks:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.LINKS]
.secondaryLinks,
}, },
} as const satisfies SettingsCompositeFieldTypeConfig<FieldLinksValue>, } as const satisfies SettingsCompositeFieldTypeConfig<FieldLinksValue>,
[FieldMetadataType.PHONES]: { [FieldMetadataType.PHONES]: {
@ -115,10 +126,18 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
'additionalPhones', 'additionalPhones',
], ],
labelBySubField: { labelBySubField: {
primaryPhoneNumber: 'Primary Phone Number', primaryPhoneNumber:
primaryPhoneCountryCode: 'Primary Phone Country Code', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.PHONES]
primaryPhoneCallingCode: 'Primary Phone Calling Code', .primaryPhoneNumber,
additionalPhones: 'Additional Phones', 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', category: 'Basic',
} as const satisfies SettingsCompositeFieldTypeConfig<FieldPhonesValue>, } as const satisfies SettingsCompositeFieldTypeConfig<FieldPhonesValue>,
@ -130,8 +149,10 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
subFields: ['firstName', 'lastName'], subFields: ['firstName', 'lastName'],
filterableSubFields: ['firstName', 'lastName'], filterableSubFields: ['firstName', 'lastName'],
labelBySubField: { labelBySubField: {
firstName: 'First Name', firstName:
lastName: 'Last Name', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].firstName,
lastName:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.FULL_NAME].lastName,
}, },
} as const satisfies SettingsCompositeFieldTypeConfig<FieldFullNameValue>, } as const satisfies SettingsCompositeFieldTypeConfig<FieldFullNameValue>,
[FieldMetadataType.ADDRESS]: { [FieldMetadataType.ADDRESS]: {
@ -156,14 +177,27 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
'addressPostcode', 'addressPostcode',
], ],
labelBySubField: { labelBySubField: {
addressStreet1: 'Address 1', addressStreet1:
addressStreet2: 'Address 2', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
addressCity: 'City', .addressStreet1,
addressState: 'State', addressStreet2:
addressCountry: 'Country', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ADDRESS]
addressPostcode: 'Post Code', .addressStreet2,
addressLat: 'Latitude', addressCity:
addressLng: 'Longitude', 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: { exampleValue: {
addressStreet1: '456 Oak Street', addressStreet1: '456 Oak Street',
@ -184,10 +218,13 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
subFields: ['source', 'name'], subFields: ['source', 'name'],
filterableSubFields: ['source', 'name'], filterableSubFields: ['source', 'name'],
labelBySubField: { labelBySubField: {
source: 'Source', source: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].source,
name: 'Name', name: COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].name,
workspaceMemberId: 'Workspace Member ID', workspaceMemberId:
context: 'Context', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR]
.workspaceMemberId,
context:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR].context,
}, },
exampleValue: { exampleValue: {
source: 'IMPORT', source: 'IMPORT',
@ -202,8 +239,12 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
subFields: ['blocknote', 'markdown'], subFields: ['blocknote', 'markdown'],
filterableSubFields: [], filterableSubFields: [],
labelBySubField: { labelBySubField: {
blocknote: 'BlockNote', blocknote:
markdown: 'Markdown', COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.blocknote,
markdown:
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.RICH_TEXT_V2]
.markdown,
}, },
exampleValue: { exampleValue: {
blocknote: '[{"type":"heading","content":"Hello"}]', blocknote: '[{"type":"heading","content":"Hello"}]',