fix(CSV export): update the csv export format for multi-select and arrray fields to match the import template format (#16450)

Closes #16432

Multi-Select and Array fields were exported in an incompatible format:
**Before**: {"0":"VALUE1","1":"VALUE2"} (object with numeric keys)
**After**: ["VALUE1","VALUE2"] (JSON array)

This prevented exported CSV files from being re-imported successfully.

### Changes
Modified useExportProcessRecordsForCSV hook to stringify Multi-Select
and Array fields as JSON arrays before CSV generation

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Abhishek Kumar
2025-12-10 19:37:43 +05:30
committed by GitHub
parent f48adb5f07
commit 89cc59d8ab
4 changed files with 181 additions and 39 deletions
@@ -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',
},
]);
});
});
@@ -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,
@@ -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<FieldMetadata>,
'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<FieldMetadata>,
'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<
@@ -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 };
};