-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f35a17b
commit fb9c271
Showing
3 changed files
with
226 additions
and
114 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
...src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | ||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; | ||
|
||
import { | ||
csvDownloader, | ||
download, | ||
generateCsv, | ||
percentage, | ||
sleep, | ||
} from '../useExportTableData'; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe('sleep', () => { | ||
it('waits the provided number of milliseconds', async () => { | ||
const spy = jest.fn(); | ||
sleep(1000).then(spy); | ||
|
||
jest.advanceTimersByTime(999); | ||
expect(spy).not.toHaveBeenCalled(); | ||
jest.advanceTimersByTime(1); | ||
await Promise.resolve(); // let queued promises execute | ||
expect(spy).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
|
||
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: 'Bar', metadata: { fieldName: 'foo' } }, | ||
] as ColumnDefinition<FieldMetadata>[]; | ||
const rows = [{ foo: 'some field', bar: 'another field' }]; | ||
const csv = generateCsv({ columns, rows }); | ||
expect(csv).toEqual(`Bar | ||
some field`); | ||
}); | ||
}); | ||
|
||
describe('csvDownloader', () => { | ||
it('downloads a csv', () => { | ||
const filename = 'test.csv'; | ||
const data = { | ||
rows: [ | ||
{ id: 1, name: 'John' }, | ||
{ id: 2, name: 'Alice' }, | ||
], | ||
columns: [], | ||
}; | ||
|
||
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('percentage', () => { | ||
it.each([ | ||
[20, 50, 40], | ||
[0, 100, 0], | ||
[10, 10, 100], | ||
[10, 10, 100], | ||
[7, 9, 78], | ||
])( | ||
'calculates the percentage %p/%p = %p', | ||
(part, whole, expectedPercentage) => { | ||
expect(percentage(part, whole)).toEqual(expectedPercentage); | ||
}, | ||
); | ||
}); |
130 changes: 130 additions & 0 deletions
130
...s/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
import { useEffect, useState } from 'react'; | ||
import { json2csv } from 'json-2-csv'; | ||
import { useRecoilValue } from 'recoil'; | ||
|
||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; | ||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | ||
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; | ||
import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; | ||
|
||
import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable'; | ||
|
||
export const sleep = (ms: number) => | ||
new Promise((resolve) => setTimeout(resolve, ms)); | ||
|
||
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; | ||
|
||
export const generateCsv: GenerateExport = ({ | ||
columns, | ||
rows, | ||
}: GenerateExportOptions): string => { | ||
const keys = columns.map((col) => ({ | ||
field: col.metadata.fieldName, | ||
title: col.label, | ||
})); | ||
return json2csv(rows, { keys }); | ||
}; | ||
|
||
export const percentage = (part: number, whole: number): number => { | ||
return Math.round((part / whole) * 100); | ||
}; | ||
|
||
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 = { | ||
delayMs: number; | ||
filename: string; | ||
limit: number; | ||
objectNameSingular: string; | ||
recordIndexId: string; | ||
}; | ||
|
||
export const useExportTableData = ({ | ||
delayMs, | ||
filename, | ||
limit, | ||
objectNameSingular, | ||
recordIndexId, | ||
}: UseExportTableDataOptions) => { | ||
const [isDownloading, setIsDownloading] = useState(false); | ||
const [inflight, setInflight] = useState(false); | ||
const [pageCount, setPageCount] = useState(0); | ||
const [progress, setProgress] = useState<number | undefined>(undefined); | ||
const [hasNextPage, setHasNextPage] = useState(true); | ||
const { getVisibleTableColumnsSelector } = | ||
useRecordTableStates(recordIndexId); | ||
const columns = useRecoilValue(getVisibleTableColumnsSelector()); | ||
const params = useFindManyParams(objectNameSingular); | ||
const { totalCount, records, fetchMoreRecords } = useFindManyRecords({ | ||
...params, | ||
onCompleted: (_data, { hasNextPage }) => { | ||
setHasNextPage(hasNextPage); | ||
}, | ||
}); | ||
|
||
useEffect(() => { | ||
const PAGE_SIZE = 30; | ||
const MAXIMUM_REQUESTS = Math.min(limit, totalCount / PAGE_SIZE); | ||
|
||
if (!isDownloading || inflight) { | ||
return; | ||
} | ||
|
||
const completeDownload = () => { | ||
setIsDownloading(false); | ||
setProgress(undefined); | ||
}; | ||
|
||
const fetchNextPage = async () => { | ||
setInflight(true); | ||
await fetchMoreRecords(); | ||
setPageCount((state) => state + 1); | ||
setProgress(percentage(pageCount, MAXIMUM_REQUESTS)); | ||
await sleep(delayMs); | ||
setInflight(false); | ||
}; | ||
|
||
if (!hasNextPage || pageCount >= limit) { | ||
csvDownloader(filename, { rows: records, columns }); | ||
completeDownload(); | ||
} else { | ||
fetchNextPage(); | ||
} | ||
}, [ | ||
delayMs, | ||
fetchMoreRecords, | ||
filename, | ||
hasNextPage, | ||
inflight, | ||
isDownloading, | ||
limit, | ||
pageCount, | ||
records, | ||
totalCount, | ||
columns, | ||
]); | ||
|
||
return { progress, download: () => setIsDownloading(true) }; | ||
}; |