diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts index 790d35893..dcd678278 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportRecords.test.ts @@ -1,7 +1,10 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; 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'; jest.useFakeTimers(); @@ -11,7 +14,11 @@ describe('generateCsv', () => { const columns = [ { label: 'Foo', metadata: { fieldName: 'foo' } }, { label: 'Empty', metadata: { fieldName: 'empty' } }, - { label: 'Nested', metadata: { fieldName: 'nested' } }, + { + label: 'Nested link field', + type: FieldMetadataType.LINKS, + metadata: { fieldName: 'nestedLinkField' }, + }, { label: 'Relation', metadata: { @@ -26,13 +33,21 @@ describe('generateCsv', () => { bar: 'another field', empty: null, 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', }, ]; const csv = generateCsv({ columns, rows }); - expect(csv).toEqual(`Id,Foo,Empty,Nested Foo,Nested Nested,Relation -1,some field,,foo,nested,a relation`); + expect(csv) + .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`); }); }); 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 a2e652630..e5c38ed3f 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 @@ -1,6 +1,7 @@ import { json2csv } from 'json-2-csv'; 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 { useExportProcessRecordsForCSV } from '@/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; @@ -10,16 +11,17 @@ import { } from '@/object-record/record-index/export/hooks/useExportFetchRecords'; 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 { t } from '@lingui/core/macro'; import { saveAs } from 'file-saver'; +import { isDefined } from 'twenty-shared/utils'; import { RelationDefinitionType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { isDefined } from 'twenty-shared/utils'; type GenerateExportOptions = { columns: ColumnDefinition[]; - rows: object[]; + rows: Record[]; }; type GenerateExport = (data: GenerateExportOptions) => string; @@ -62,31 +64,20 @@ export const generateCsv: GenerateExport = ({ .join(' '), }; - const fieldsWithSubFields = rows.find((row) => { - const fieldValue = (row as any)[column.field]; + const columnType = col.type; + if (!isCompositeFieldType(columnType)) return [column]; - const hasSubFields = - fieldValue && - typeof fieldValue === 'object' && - !Array.isArray(fieldValue); - - return hasSubFields; - }); - - if (isDefined(fieldsWithSubFields)) { - const nestedFieldsWithoutTypename = Object.keys( - (fieldsWithSubFields as any)[column.field], - ) - .filter((key) => key !== '__typename') - .map((key) => ({ + const nestedFieldsWithoutTypename = Object.keys(rows[0][column.field]) + .filter((key) => key !== '__typename') + .map((key) => { + const subFieldLabel = COMPOSITE_FIELD_SUB_FIELD_LABELS[columnType][key]; + return { field: `${column.field}.${key}`, - title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`, - })); + title: `${column.title} / ${subFieldLabel}`, + }; + }); - return nestedFieldsWithoutTypename; - } - - return [column]; + return nestedFieldsWithoutTypename; }); return json2csv(rows, { diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/CompositeFieldSubFieldLabel.ts b/packages/twenty-front/src/modules/settings/data-model/constants/CompositeFieldSubFieldLabel.ts new file mode 100644 index 000000000..b70da449d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/data-model/constants/CompositeFieldSubFieldLabel.ts @@ -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; +} = { + [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', + }, +}; 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 10c15e8de..5b92fa58d 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 @@ -9,6 +9,7 @@ import { FieldPhonesValue, FieldRichTextV2Value, } 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 { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType'; import { ConnectedAccountProvider } from 'twenty-shared/types'; @@ -43,8 +44,12 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { subFields: ['amountMicros', 'currencyCode'], filterableSubFields: ['amountMicros', 'currencyCode'], labelBySubField: { - amountMicros: 'Amount', - currencyCode: 'Currency', + amountMicros: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] + .amountMicros, + currencyCode: + COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.CURRENCY] + .currencyCode, }, exampleValue: { amountMicros: 2000000000, @@ -87,9 +92,15 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { 'secondaryLinks', ], labelBySubField: { - primaryLinkUrl: 'Link URL', - primaryLinkLabel: 'Link Label', - secondaryLinks: 'Secondary Links', + 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, }, } as const satisfies SettingsCompositeFieldTypeConfig, [FieldMetadataType.PHONES]: { @@ -115,10 +126,18 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { 'additionalPhones', ], labelBySubField: { - primaryPhoneNumber: 'Primary Phone Number', - primaryPhoneCountryCode: 'Primary Phone Country Code', - primaryPhoneCallingCode: 'Primary Phone Calling Code', - additionalPhones: 'Additional Phones', + 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, @@ -130,8 +149,10 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { subFields: ['firstName', 'lastName'], filterableSubFields: ['firstName', 'lastName'], labelBySubField: { - firstName: 'First Name', - lastName: 'Last Name', + 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]: { @@ -156,14 +177,27 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { 'addressPostcode', ], labelBySubField: { - addressStreet1: 'Address 1', - addressStreet2: 'Address 2', - addressCity: 'City', - addressState: 'State', - addressCountry: 'Country', - addressPostcode: 'Post Code', - addressLat: 'Latitude', - addressLng: 'Longitude', + 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', @@ -184,10 +218,13 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { subFields: ['source', 'name'], filterableSubFields: ['source', 'name'], labelBySubField: { - source: 'Source', - name: 'Name', - workspaceMemberId: 'Workspace Member ID', - context: 'Context', + 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', @@ -202,8 +239,12 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = { subFields: ['blocknote', 'markdown'], filterableSubFields: [], labelBySubField: { - blocknote: 'BlockNote', - markdown: 'Markdown', + 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"}]',