Fix record board export not taking filters into account (#8505)

Fix Export CSV action not taking into account the filters applied on the
Kanban index view
This commit is contained in:
Charles Bochet
2024-11-14 23:28:53 +01:00
committed by GitHub
parent a799370483
commit 5384b4aba5
11 changed files with 110 additions and 94 deletions

View File

@ -1,13 +1,13 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
displayedExportProgress,
useExportRecordData,
} from '@/action-menu/hooks/useExportRecordData';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconDatabaseExport } from 'twenty-ui';
import {
displayedExportProgress,
useExportRecords,
} from '@/object-record/record-index/export/hooks/useExportRecords';
import { useEffect } from 'react';
export const ExportRecordsActionEffect = ({
@ -22,7 +22,7 @@ export const ExportRecordsActionEffect = ({
contextStoreNumberOfSelectedRecordsComponentState,
);
const { progress, download } = useExportRecordData({
const { progress, download } = useExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId: objectMetadataItem.namePlural,

View File

@ -1,107 +0,0 @@
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 {
csvDownloader,
displayedExportProgress,
download,
generateCsv,
} from '../useExportRecordData';
jest.useFakeTimers();
describe('download', () => {
it('creates a download link and clicks it', () => {
const link = document.createElement('a');
document.createElement = jest.fn().mockReturnValue(link);
const appendChild = jest.spyOn(document.body, 'appendChild');
const click = jest.spyOn(link, 'click');
URL.createObjectURL = jest.fn().mockReturnValue('fake-url');
download(new Blob(['test'], { type: 'text/plain' }), 'test.txt');
expect(appendChild).toHaveBeenCalledWith(link);
expect(link.href).toEqual('http://localhost/fake-url');
expect(link.getAttribute('download')).toEqual('test.txt');
expect(click).toHaveBeenCalledTimes(1);
});
});
describe('generateCsv', () => {
it('generates a csv with formatted headers', async () => {
const columns = [
{ label: 'Foo', metadata: { fieldName: 'foo' } },
{ label: 'Empty', metadata: { fieldName: 'empty' } },
{ label: 'Nested', metadata: { fieldName: 'nested' } },
{
label: 'Relation',
metadata: {
fieldName: 'relation',
relationType: RelationDefinitionType.ManyToOne,
},
},
] as ColumnDefinition<FieldMetadata>[];
const rows = [
{
id: '1',
bar: 'another field',
empty: null,
foo: 'some field',
nested: { __typename: 'type', foo: 'foo', nested: 'nested' },
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`);
});
});
describe('csvDownloader', () => {
it('downloads a csv', () => {
const filename = 'test.csv';
const data = {
rows: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Alice' },
],
columns: [],
objectNameSingular: '',
};
const link = document.createElement('a');
document.createElement = jest.fn().mockReturnValue(link);
const createObjectURL = jest.spyOn(URL, 'createObjectURL');
csvDownloader(filename, data);
expect(link.getAttribute('download')).toEqual('test.csv');
expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
expect(createObjectURL).toHaveBeenCalledWith(
expect.objectContaining({ type: 'text/csv' }),
);
});
});
describe('displayedExportProgress', () => {
it.each([
[undefined, undefined, 'percentage', 'Export View as CSV'],
[20, 50, 'percentage', 'Export (40%)'],
[0, 100, 'number', 'Export (0)'],
[10, 10, 'percentage', 'Export (100%)'],
[10, 10, 'number', 'Export (10)'],
[7, 9, 'percentage', 'Export (78%)'],
])(
'displays the export progress',
(exportedRecordCount, totalRecordCount, displayType, expected) => {
expect(
displayedExportProgress('all', {
exportedRecordCount,
totalRecordCount,
displayType: displayType as 'percentage' | 'number',
}),
).toEqual(expected);
},
);
});

View File

@ -1,179 +0,0 @@
import { json2csv } from 'json-2-csv';
import { useMemo } from 'react';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize';
import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport';
import {
UseRecordDataOptions,
useRecordData,
} from '@/object-record/record-index/options/hooks/useRecordData';
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const download = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
};
type GenerateExportOptions = {
columns: ColumnDefinition<FieldMetadata>[];
rows: object[];
};
type GenerateExport = (data: GenerateExportOptions) => string;
type ExportProgress = {
exportedRecordCount?: number;
totalRecordCount?: number;
displayType: 'percentage' | 'number';
};
export const generateCsv: GenerateExport = ({
columns,
rows,
}: GenerateExportOptions): string => {
const columnsToExport = columns.filter(
(col) =>
!('relationType' in col.metadata && col.metadata.relationType) ||
col.metadata.relationType === RelationDefinitionType.ManyToOne,
);
const objectIdColumn: ColumnDefinition<FieldMetadata> = {
fieldMetadataId: '',
type: FieldMetadataType.Uuid,
iconName: '',
label: `Id`,
metadata: {
fieldName: 'id',
},
position: 0,
size: 0,
};
const columnsToExportWithIdColumn = [objectIdColumn, ...columnsToExport];
const keys = columnsToExportWithIdColumn.flatMap((col) => {
const column = {
field: `${col.metadata.fieldName}${col.type === 'RELATION' ? 'Id' : ''}`,
title: [col.label, col.type === 'RELATION' ? 'Id' : null]
.filter(isDefined)
.join(' '),
};
const fieldsWithSubFields = rows.find((row) => {
const fieldValue = (row as any)[column.field];
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) => ({
field: `${column.field}.${key}`,
title: `${column.title} ${key[0].toUpperCase() + key.slice(1)}`,
}));
return nestedFieldsWithoutTypename;
}
return [column];
});
return json2csv(rows, {
keys,
emptyFieldValue: '',
});
};
const percentage = (part: number, whole: number): number => {
return Math.round((part / whole) * 100);
};
export const displayedExportProgress = (
mode: 'all' | 'selection' = 'all',
progress?: ExportProgress,
): string => {
if (isUndefinedOrNull(progress?.exportedRecordCount)) {
return mode === 'all' ? 'Export View as CSV' : 'Export Selection as CSV';
}
if (
progress.displayType === 'percentage' &&
isDefined(progress?.totalRecordCount)
) {
return `Export (${percentage(
progress.exportedRecordCount,
progress.totalRecordCount,
)}%)`;
}
return `Export (${progress.exportedRecordCount})`;
};
const downloader = (mimeType: string, generator: GenerateExport) => {
return (filename: string, data: GenerateExportOptions) => {
const blob = new Blob([generator(data)], { type: mimeType });
download(blob, filename);
};
};
export const csvDownloader = downloader('text/csv', generateCsv);
type UseExportTableDataOptions = Omit<UseRecordDataOptions, 'callback'> & {
filename: string;
};
export const useExportRecordData = ({
delayMs,
filename,
maximumRequests = 100,
objectMetadataItem,
pageSize = EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE,
recordIndexId,
viewType,
}: UseExportTableDataOptions) => {
const { processRecordsForCSVExport } = useProcessRecordsForCSVExport(
objectMetadataItem.nameSingular,
);
const downloadCsv = useMemo(
() =>
(records: ObjectRecord[], columns: ColumnDefinition<FieldMetadata>[]) => {
const recordsProcessedForExport = processRecordsForCSVExport(records);
csvDownloader(filename, { rows: recordsProcessedForExport, columns });
},
[filename, processRecordsForCSVExport],
);
const { getTableData: download, progress } = useRecordData({
delayMs,
maximumRequests,
objectMetadataItem,
pageSize,
recordIndexId,
callback: downloadCsv,
viewType,
});
return { progress, download };
};