diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts index b9461af41c..205b295b6a 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/__tests__/useExportProcessRecordsForCSV.test.ts @@ -9,6 +9,8 @@ jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({ fields: [ { type: FieldMetadataType.CURRENCY, name: 'price' }, { type: FieldMetadataType.TEXT, name: 'name' }, + { type: FieldMetadataType.MULTI_SELECT, name: 'tags' }, + { type: FieldMetadataType.ARRAY, name: 'skills' }, ], }, })), @@ -56,4 +58,82 @@ describe('useExportProcessRecordsForCSV', () => { }, ]); }); + + it('processes records with multi-select and array fields correctly', () => { + const { result } = renderHook(() => + useExportProcessRecordsForCSV('someObject'), + ); + + const records = [ + { + __typename: 'ObjectRecord', + id: '1', + tags: ['TAG1', 'TAG2'], + skills: ['skill1', 'skill2', 'skill3'], + name: 'Item 1', + }, + { + __typename: 'ObjectRecord', + id: '2', + tags: ['TAG3'], + skills: ['skill4'], + name: 'Item 2', + }, + ]; + + let processedRecords; + + act(() => { + processedRecords = result.current.processRecordsForCSVExport(records); + }); + + expect(processedRecords).toEqual([ + { + __typename: 'ObjectRecord', + id: '1', + tags: '["TAG1","TAG2"]', + skills: '["skill1","skill2","skill3"]', + name: 'Item 1', + }, + { + __typename: 'ObjectRecord', + id: '2', + tags: '["TAG3"]', + skills: '["skill4"]', + name: 'Item 2', + }, + ]); + }); + + it('processes records with empty multi-select and array fields correctly', () => { + const { result } = renderHook(() => + useExportProcessRecordsForCSV('someObject'), + ); + + const records = [ + { + __typename: 'ObjectRecord', + id: '1', + tags: [], + skills: [], + name: 'Item 1', + }, + ]; + + let processedRecords; + + act(() => { + processedRecords = result.current.processRecordsForCSVExport(records); + }); + + expect(processedRecords).toEqual([ + { + __typename: 'ObjectRecord', + id: '1', + tags: '[]', + skills: '[]', + name: 'Item 1', + }, + ]); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts index 112ea10584..2a9c173af7 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useExportProcessRecordsForCSV.ts @@ -29,6 +29,8 @@ export const useExportProcessRecordsForCSV = (objectNameSingular: string) => { currencyCode: record[field.name].currencyCode, } satisfies FieldCurrencyValue, }; + case FieldMetadataType.MULTI_SELECT: + case FieldMetadataType.ARRAY: case FieldMetadataType.RAW_JSON: return { ...processedRecord, diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useRecordIndexExportRecords.test.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useRecordIndexExportRecords.test.ts index 7eaf204856..34434ac8c3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useRecordIndexExportRecords.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useRecordIndexExportRecords.test.ts @@ -67,6 +67,105 @@ describe('generateCsv', () => { 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`); }); + it('generates csv with multi-select and array fields as JSON arrays', () => { + const columns: Pick< + ColumnDefinition, + 'size' | 'label' | 'type' | 'metadata' + >[] = [ + { + label: 'Name', + size: 100, + type: FieldMetadataType.TEXT, + metadata: { fieldName: 'name' }, + }, + { + label: 'Tags', + size: 120, + type: FieldMetadataType.MULTI_SELECT, + metadata: { fieldName: 'tags' }, + }, + { + label: 'Skills', + size: 150, + type: FieldMetadataType.ARRAY, + metadata: { fieldName: 'skills' }, + }, + ]; + + const rows = [ + { + id: '1', + name: 'John Doe', + tags: '["DISTRIBUTOR","IMPLEMENTATION"]', + skills: '["JavaScript","TypeScript","React"]', + }, + { + id: '2', + name: 'Jane Smith', + tags: '["PARTNER"]', + skills: '["Python","Django"]', + }, + ]; + + const csv = generateCsv({ columns, rows }); + + expect(csv).toContain('[""DISTRIBUTOR"",""IMPLEMENTATION""]'); + expect(csv).toContain('[""JavaScript"",""TypeScript"",""React""]'); + expect(csv).toContain('[""PARTNER""]'); + expect(csv).toContain('[""Python"",""Django""]'); + + expect(csv).not.toContain('{"0":"DISTRIBUTOR","1":"IMPLEMENTATION"}'); + expect(csv).not.toContain( + '{"0":"JavaScript","1":"TypeScript","2":"React"}', + ); + + expect(csv).toContain('Id,Name,Tags,Skills'); + expect(csv).toContain('1,John Doe'); + expect(csv).toContain('2,Jane Smith'); + }); + + it('generates csv with empty multi-select and array fields as empty JSON arrays', () => { + const columns: Pick< + ColumnDefinition, + 'size' | 'label' | 'type' | 'metadata' + >[] = [ + { + label: 'Name', + size: 100, + type: FieldMetadataType.TEXT, + metadata: { fieldName: 'name' }, + }, + { + label: 'Tags', + size: 120, + type: FieldMetadataType.MULTI_SELECT, + metadata: { fieldName: 'tags' }, + }, + { + label: 'Skills', + size: 150, + type: FieldMetadataType.ARRAY, + metadata: { fieldName: 'skills' }, + }, + ]; + + const rows = [ + { + id: '1', + name: 'John Doe', + tags: '[]', + skills: '[]', + }, + ]; + + const csv = generateCsv({ columns, rows }); + + expect(csv).toContain('[]'); + + expect(csv).toContain('Id,Name,Tags,Skills'); + expect(csv).toContain('1,John Doe,[],[]'); + }); + describe('CSV Injection Prevention with ZWJ', () => { it('prevents formula injection with equals sign using ZWJ prefix', () => { const columns: Pick< diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportProcessRecordsForCSV.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportProcessRecordsForCSV.ts deleted file mode 100644 index 279abde83d..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportProcessRecordsForCSV.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { type FieldCurrencyValue } from '@/object-record/record-field/ui/types/FieldMetadata'; -import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { isDefined } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { convertCurrencyMicrosToCurrencyAmount } from '~/utils/convertCurrencyToCurrencyMicros'; - -export const useExportProcessRecordsForCSV = (objectNameSingular: string) => { - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const processRecordsForCSVExport = (records: ObjectRecord[]) => { - return records.map((record) => { - const currencyFields = objectMetadataItem.fields.filter( - (field) => field.type === FieldMetadataType.CURRENCY, - ); - - const processedRecord = { - ...record, - }; - - for (const currencyField of currencyFields) { - if (isDefined(record[currencyField.name])) { - processedRecord[currencyField.name] = { - amountMicros: convertCurrencyMicrosToCurrencyAmount( - record[currencyField.name].amountMicros, - ), - currencyCode: record[currencyField.name].currencyCode, - } satisfies FieldCurrencyValue; - } - } - - return processedRecord; - }); - }; - - return { processRecordsForCSVExport }; -};