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:
@ -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,
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user