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:
+80
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+99
@@ -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<
|
||||
|
||||
-39
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user