Skip to content

Commit

Permalink
add tests
Browse files Browse the repository at this point in the history
  • Loading branch information
tatethurston committed Feb 18, 2024
1 parent f35a17b commit fb9c271
Show file tree
Hide file tree
Showing 3 changed files with 226 additions and 114 deletions.
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { json2csv } from 'json-2-csv';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';

import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport';
import {
Expand All @@ -29,8 +26,7 @@ import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFiel
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useViewBar } from '@/views/hooks/useViewBar';
import { ViewType } from '@/views/types/ViewType';

import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable';
import { useExportTableData } from '../hooks/useExportTableData';

type RecordIndexOptionsMenu = 'fields';

Expand All @@ -40,114 +36,6 @@ type RecordIndexOptionsDropdownContentProps = {
viewType: ViewType;
};

type UseExportTableDataOptions = {
delayMs: number;
filename: string;
limit: number;
objectNameSingular: string;
recordIndexId: string;
};

const useExportTableData = ({
delayMs,
filename,
limit,
objectNameSingular,
recordIndexId,
}: UseExportTableDataOptions) => {
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);
};

const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));

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(() => {
if (!isDownloading || inflight) {
return;
}

const CONVERSION_PERCENT_CONSTANT = 10;
const DOWNLOAD_MAX_PERCENT = 100 - CONVERSION_PERCENT_CONSTANT;
const PAGE_SIZE = 30;
const MAXIMUM_REQUESTS = Math.max(limit, totalCount / PAGE_SIZE);

const completeDownload = () => {
setIsDownloading(false);
setProgress(undefined);
};

const downloadCsv = () => {
setProgress(DOWNLOAD_MAX_PERCENT);
const keys = columns.map((col) => ({
field: col.metadata.fieldName,
title: col.label,
}));
const csv = json2csv(records, { keys });
const blob = new Blob([csv], { type: 'text/csv' });
setProgress(100);
download(blob, filename);
completeDownload();
};

const downloadProgress = () => {
const percentOfRequestsCompleted = pageCount / MAXIMUM_REQUESTS;
return Math.round(DOWNLOAD_MAX_PERCENT * percentOfRequestsCompleted);
};

const fetchNextPage = async () => {
setInflight(true);
await fetchMoreRecords();
setPageCount((state) => state + 1);
setProgress(downloadProgress());
await sleep(delayMs);
setInflight(false);
};

if (!hasNextPage || pageCount >= limit) {
downloadCsv();
} else {
fetchNextPage();
}
}, [
delayMs,
fetchMoreRecords,
filename,
hasNextPage,
inflight,
isDownloading,
limit,
pageCount,
records,
totalCount,
columns,
]);

return { progress, download: () => setIsDownloading(true) };
};

export const RecordIndexOptionsDropdownContent = ({
viewType,
recordIndexId,
Expand Down
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);
},
);
});
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) };
};

0 comments on commit fb9c271

Please sign in to comment.