mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Add download csv functionality to search tables (#939)
Adds a download icon that allows users to download a csv of results. Note: In v1, this was a gear icon that brought up a modal with a few different search options, including adding additional columns. Since that functionality doesn't exist in v2 yet, I thought it was best to just have a direct icon for now.  Fixes: HDX-1590
This commit is contained in:
parent
b75d7c0595
commit
a06c8cdb9d
8 changed files with 626 additions and 85 deletions
5
.changeset/tall-actors-bow.md
Normal file
5
.changeset/tall-actors-bow.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
feat: Add download csv functionality to search tables
|
||||
|
|
@ -73,7 +73,6 @@
|
|||
"react-bootstrap": "^2.4.0",
|
||||
"react-bootstrap-range-slider": "^3.0.8",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-dom": "18.3.1",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-grid-layout": "^1.3.4",
|
||||
|
|
@ -83,6 +82,7 @@
|
|||
"react-json-tree": "^0.17.0",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-modern-drawer": "^1.2.0",
|
||||
"react-papaparse": "^4.4.0",
|
||||
"react-query": "^3.39.3",
|
||||
"react-select": "^5.7.0",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
|
|
@ -125,7 +125,6 @@
|
|||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||
"@types/react-csv": "^1.1.3",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@types/react-grid-layout": "^1.3.2",
|
||||
"@types/react-syntax-highlighter": "^13.5.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
import {
|
||||
flexRender,
|
||||
|
|
@ -13,29 +12,12 @@ import {
|
|||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { CsvExportButton } from './components/CsvExportButton';
|
||||
import { useCsvExport } from './hooks/useCsvExport';
|
||||
import { UNDEFINED_WIDTH } from './tableUtils';
|
||||
import type { NumberFormat } from './types';
|
||||
import { formatNumber } from './utils';
|
||||
|
||||
export const generateCsvData = (
|
||||
data: any[],
|
||||
columns: {
|
||||
dataKey: string;
|
||||
displayName: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
numberFormat?: NumberFormat;
|
||||
columnWidthPercent?: number;
|
||||
}[],
|
||||
groupColumnName?: string,
|
||||
) => {
|
||||
return data.map(row => ({
|
||||
...(groupColumnName != null ? { [groupColumnName]: row.group } : {}),
|
||||
...Object.fromEntries(
|
||||
columns.map(({ displayName, dataKey }) => [displayName, row[dataKey]]),
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
export const Table = ({
|
||||
data,
|
||||
groupColumnName,
|
||||
|
|
@ -177,9 +159,14 @@ export const Table = ({
|
|||
[items, rowVirtualizer.options.scrollMargin, totalSize],
|
||||
);
|
||||
|
||||
const csvData = useMemo(() => {
|
||||
return generateCsvData(data, columns, groupColumnName);
|
||||
}, [data, columns, groupColumnName]);
|
||||
const { csvData } = useCsvExport(
|
||||
data,
|
||||
columns.map(col => ({
|
||||
dataKey: col.dataKey,
|
||||
displayName: col.displayName,
|
||||
})),
|
||||
{ groupColumnName },
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -242,33 +229,30 @@ export const Table = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-grab ${
|
||||
header.column.getIsResizing()
|
||||
? 'isResizing'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() &&
|
||||
headerIndex !== headerGroup.headers.length - 1 && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-grab ${
|
||||
header.column.getIsResizing()
|
||||
? 'isResizing'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
{headerIndex === headerGroup.headers.length - 1 && (
|
||||
<div className="d-flex align-items-center">
|
||||
<CSVLink
|
||||
<CsvExportButton
|
||||
data={csvData}
|
||||
filename={`HyperDX_table_results`}
|
||||
filename="HyperDX_table_results"
|
||||
className="fs-8 text-muted-hover ms-2"
|
||||
title="Download table as CSV"
|
||||
>
|
||||
<div
|
||||
className="fs-8 text-muted-hover ms-2"
|
||||
role="button"
|
||||
title="Download table as CSV"
|
||||
>
|
||||
<i className="bi bi-download" />
|
||||
</div>
|
||||
</CSVLink>
|
||||
<i className="bi bi-download" />
|
||||
</CsvExportButton>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
|
|
|
|||
93
packages/app/src/components/CsvExportButton.tsx
Normal file
93
packages/app/src/components/CsvExportButton.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { useCSVDownloader } from 'react-papaparse';
|
||||
|
||||
interface CsvExportButtonProps {
|
||||
data: Record<string, any>[];
|
||||
filename: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onExportStart?: () => void;
|
||||
onExportComplete?: () => void;
|
||||
onExportError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const CsvExportButton: React.FC<CsvExportButtonProps> = ({
|
||||
data,
|
||||
filename,
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
disabled = false,
|
||||
onExportStart,
|
||||
onExportComplete,
|
||||
onExportError,
|
||||
...props
|
||||
}) => {
|
||||
const { CSVDownloader } = useCSVDownloader();
|
||||
|
||||
const handleClick = () => {
|
||||
try {
|
||||
if (data.length === 0) {
|
||||
onExportError?.(new Error('No data to export'));
|
||||
return;
|
||||
}
|
||||
|
||||
onExportStart?.();
|
||||
onExportComplete?.();
|
||||
} catch (error) {
|
||||
onExportError?.(
|
||||
error instanceof Error ? error : new Error('Export failed'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled || data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
title={disabled ? 'Export disabled' : 'No data to export'}
|
||||
style={{ opacity: 0.5, cursor: 'not-allowed' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="button"
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<CSVDownloader
|
||||
data={data}
|
||||
filename={filename}
|
||||
config={{
|
||||
quotes: true,
|
||||
quoteChar: '"',
|
||||
escapeChar: '"',
|
||||
delimiter: ',',
|
||||
header: true,
|
||||
}}
|
||||
style={{
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CSVDownloader>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,7 +3,6 @@ import cx from 'classnames';
|
|||
import { isString } from 'lodash';
|
||||
import curry from 'lodash/curry';
|
||||
import { Button, Modal } from 'react-bootstrap';
|
||||
import { CSVLink } from 'react-csv';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
Bar,
|
||||
|
|
@ -43,6 +42,7 @@ import {
|
|||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { useCsvExport } from '@/hooks/useCsvExport';
|
||||
import { useTableMetadata } from '@/hooks/useMetadata';
|
||||
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
|
||||
import { useGroupedPatterns } from '@/hooks/usePatterns';
|
||||
|
|
@ -60,9 +60,11 @@ import {
|
|||
} from '@/utils';
|
||||
|
||||
import { SQLPreview } from './ChartSQLPreview';
|
||||
import { CsvExportButton } from './CsvExportButton';
|
||||
import LogLevel from './LogLevel';
|
||||
|
||||
import styles from '../../styles/LogTable.module.scss';
|
||||
|
||||
type Row = Record<string, any> & { duration: number };
|
||||
type AccessorFn = (row: Row, column: string) => any;
|
||||
|
||||
|
|
@ -315,6 +317,14 @@ export const RawLogTable = memo(
|
|||
return inferLogLevelColumn(dedupedRows);
|
||||
}, [dedupedRows]);
|
||||
|
||||
const { csvData, maxRows, isLimited } = useCsvExport(
|
||||
dedupedRows,
|
||||
displayedColumns.map(col => ({
|
||||
dataKey: col,
|
||||
displayName: columnNameMap?.[col] ?? col,
|
||||
})),
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<any>[]>(
|
||||
() => [
|
||||
{
|
||||
|
|
@ -632,24 +642,25 @@ export const RawLogTable = memo(
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-col-resize ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
{header.column.getCanResize() &&
|
||||
headerIndex !== headerGroup.headers.length - 1 && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer text-gray-600 cursor-col-resize ${
|
||||
header.column.getIsResizing() ? 'isResizing' : ''
|
||||
}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 12,
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-three-dots-vertical" />
|
||||
</div>
|
||||
)}
|
||||
{headerIndex === headerGroup.headers.length - 1 && (
|
||||
<div
|
||||
className="d-flex align-items-center"
|
||||
|
|
@ -671,6 +682,14 @@ export const RawLogTable = memo(
|
|||
<i className="bi bi-arrow-clockwise" />
|
||||
</div>
|
||||
)}
|
||||
<CsvExportButton
|
||||
data={csvData}
|
||||
filename={`hyperdx_search_results_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`}
|
||||
className="fs-6 text-muted-hover ms-2"
|
||||
title={`Download table as CSV (max ${maxRows.toLocaleString()} rows)${isLimited ? ' - data truncated' : ''}`}
|
||||
>
|
||||
<i className="bi bi-download" />
|
||||
</CsvExportButton>
|
||||
{onSettingsClick != null && (
|
||||
<div
|
||||
className="fs-8 text-muted-hover ms-2"
|
||||
|
|
|
|||
309
packages/app/src/hooks/__tests__/useCsvExport.test.tsx
Normal file
309
packages/app/src/hooks/__tests__/useCsvExport.test.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { CsvColumn, useCsvExport } from '../useCsvExport';
|
||||
|
||||
describe('useCsvExport', () => {
|
||||
const mockColumns: CsvColumn[] = [
|
||||
{ dataKey: 'name', displayName: 'Name' },
|
||||
{ dataKey: 'age', displayName: 'Age' },
|
||||
{ dataKey: 'email', displayName: 'Email Address' },
|
||||
];
|
||||
|
||||
const mockData = [
|
||||
{ name: 'John Doe', age: 30, email: 'john@example.com' },
|
||||
{ name: 'Jane Smith', age: 25, email: 'jane@example.com' },
|
||||
{ name: 'Bob Wilson', age: 35, email: 'bob@example.com' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('transforms data correctly with valid inputs', () => {
|
||||
const { result } = renderHook(() => useCsvExport(mockData, mockColumns));
|
||||
|
||||
expect(result.current.csvData).toEqual([
|
||||
{ Name: 'John Doe', Age: '30', 'Email Address': 'john@example.com' },
|
||||
{ Name: 'Jane Smith', Age: '25', 'Email Address': 'jane@example.com' },
|
||||
{ Name: 'Bob Wilson', Age: '35', 'Email Address': 'bob@example.com' },
|
||||
]);
|
||||
expect(result.current.actualRowCount).toBe(3);
|
||||
expect(result.current.isDataEmpty).toBe(false);
|
||||
expect(result.current.isLimited).toBe(false);
|
||||
});
|
||||
|
||||
it('uses display names as CSV headers', () => {
|
||||
const { result } = renderHook(() => useCsvExport(mockData, mockColumns));
|
||||
const csvData = result.current.csvData;
|
||||
|
||||
expect(csvData[0]).toHaveProperty('Name');
|
||||
expect(csvData[0]).toHaveProperty('Age');
|
||||
expect(csvData[0]).toHaveProperty('Email Address');
|
||||
expect(csvData[0]).not.toHaveProperty('name');
|
||||
expect(csvData[0]).not.toHaveProperty('email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data type handling', () => {
|
||||
it('handles null and undefined values', () => {
|
||||
const dataWithNulls = [
|
||||
{ name: 'John', age: null, email: undefined },
|
||||
{ name: null, age: 30, email: 'test@example.com' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(dataWithNulls, mockColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toEqual([
|
||||
{ Name: 'John', Age: '', 'Email Address': '' },
|
||||
{ Name: '', Age: '30', 'Email Address': 'test@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles object values by JSON stringifying them', () => {
|
||||
const dataWithObjects = [
|
||||
{
|
||||
name: 'John',
|
||||
age: 30,
|
||||
email: {
|
||||
primary: 'john@example.com',
|
||||
secondary: 'john2@example.com',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(dataWithObjects, mockColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData[0]['Email Address']).toBe(
|
||||
'{"primary":"john@example.com","secondary":"john2@example.com"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles values with commas correctly', () => {
|
||||
const dataWithCommas = [
|
||||
{ name: 'Doe, John', age: 30, email: 'john@example.com' },
|
||||
{ name: 'Smith, Jane', age: 25, email: 'jane@test,domain.com' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(dataWithCommas, mockColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toEqual([
|
||||
{ Name: 'Doe, John', Age: '30', 'Email Address': 'john@example.com' },
|
||||
{
|
||||
Name: 'Smith, Jane',
|
||||
Age: '25',
|
||||
'Email Address': 'jane@test,domain.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data filtering', () => {
|
||||
it('filters out non-object rows', () => {
|
||||
const mixedData = [
|
||||
{ name: 'John', age: 30, email: 'john@example.com' },
|
||||
'invalid string',
|
||||
42,
|
||||
null,
|
||||
undefined,
|
||||
{ name: 'Jane', age: 25, email: 'jane@example.com' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => useCsvExport(mixedData, mockColumns));
|
||||
|
||||
expect(result.current.csvData).toHaveLength(2);
|
||||
expect(result.current.csvData).toEqual([
|
||||
{ Name: 'John', Age: '30', 'Email Address': 'john@example.com' },
|
||||
{ Name: 'Jane', Age: '25', 'Email Address': 'jane@example.com' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out rows that error during processing', () => {
|
||||
const problematicData = [
|
||||
{ name: 'John', age: 30, email: 'john@example.com' },
|
||||
{
|
||||
get name() {
|
||||
throw new Error('Getter error');
|
||||
},
|
||||
age: 25,
|
||||
email: 'error@example.com',
|
||||
},
|
||||
{ name: 'Jane', age: 25, email: 'jane@example.com' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(problematicData, mockColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toHaveLength(2);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error processing row 1:'),
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('row limiting', () => {
|
||||
it('limits rows to maxRows option', () => {
|
||||
const largeData = Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `Person ${i}`,
|
||||
age: 20 + i,
|
||||
email: `person${i}@example.com`,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(largeData, mockColumns, { maxRows: 5 }),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toHaveLength(5);
|
||||
expect(result.current.isLimited).toBe(true);
|
||||
expect(result.current.maxRows).toBe(5);
|
||||
});
|
||||
|
||||
it('uses default max rows (4000)', () => {
|
||||
const { result } = renderHook(() => useCsvExport(mockData, mockColumns));
|
||||
expect(result.current.maxRows).toBe(4000);
|
||||
});
|
||||
|
||||
it('indicates when data is not limited', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(mockData, mockColumns, { maxRows: 10 }),
|
||||
);
|
||||
|
||||
expect(result.current.isLimited).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group column functionality', () => {
|
||||
it('adds group column when specified', () => {
|
||||
const dataWithGroups = [
|
||||
{ name: 'John', age: 30, email: 'john@example.com', group: 'A' },
|
||||
{ name: 'Jane', age: 25, email: 'jane@example.com', group: 'B' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(dataWithGroups, mockColumns, {
|
||||
groupColumnName: 'Category',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toEqual([
|
||||
{
|
||||
Category: 'A',
|
||||
Name: 'John',
|
||||
Age: '30',
|
||||
'Email Address': 'john@example.com',
|
||||
},
|
||||
{
|
||||
Category: 'B',
|
||||
Name: 'Jane',
|
||||
Age: '25',
|
||||
'Email Address': 'jane@example.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles missing group values', () => {
|
||||
const dataWithMissingGroups = [
|
||||
{ name: 'John', age: 30, email: 'john@example.com' },
|
||||
{ name: 'Jane', age: 25, email: 'jane@example.com', group: 'B' },
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(dataWithMissingGroups, mockColumns, {
|
||||
groupColumnName: 'Category',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.csvData[0]).toHaveProperty('Category', '');
|
||||
expect(result.current.csvData[1]).toHaveProperty('Category', 'B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('handles empty data array', () => {
|
||||
const { result } = renderHook(() => useCsvExport([], mockColumns));
|
||||
|
||||
expect(result.current.csvData).toEqual([]);
|
||||
expect(result.current.isDataEmpty).toBe(true);
|
||||
expect(result.current.actualRowCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles non-array data', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport('not an array' as any, mockColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toEqual([]);
|
||||
expect(result.current.isDataEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('handles empty columns array', () => {
|
||||
const { result } = renderHook(() => useCsvExport(mockData, []));
|
||||
|
||||
expect(result.current.csvData).toEqual([]);
|
||||
expect(result.current.isDataEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('handles invalid column structure', () => {
|
||||
const invalidColumns = [
|
||||
{ dataKey: 'name', displayName: 'Name' },
|
||||
{ dataKey: '', displayName: 'Invalid' }, // Empty dataKey
|
||||
{ dataKey: 'age' } as any, // Missing displayName
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCsvExport(mockData, invalidColumns),
|
||||
);
|
||||
|
||||
expect(result.current.csvData).toEqual([]);
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
'CSV Export: Invalid column structure detected',
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoization', () => {
|
||||
it('memoizes result when inputs are unchanged', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ data, columns }) => useCsvExport(data, columns),
|
||||
{ initialProps: { data: mockData, columns: mockColumns } },
|
||||
);
|
||||
|
||||
const firstResult = result.current;
|
||||
|
||||
rerender({ data: mockData, columns: mockColumns });
|
||||
|
||||
expect(result.current).toBe(firstResult);
|
||||
});
|
||||
|
||||
it('recalculates when data changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ data }) => useCsvExport(data, mockColumns),
|
||||
{ initialProps: { data: mockData } },
|
||||
);
|
||||
|
||||
const firstResult = result.current;
|
||||
const newData = [
|
||||
...mockData,
|
||||
{ name: 'New Person', age: 40, email: 'new@example.com' },
|
||||
];
|
||||
|
||||
rerender({ data: newData });
|
||||
|
||||
expect(result.current).not.toBe(firstResult);
|
||||
expect(result.current.csvData).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
123
packages/app/src/hooks/useCsvExport.tsx
Normal file
123
packages/app/src/hooks/useCsvExport.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
const DEFAULT_MAX_ROWS = 4000;
|
||||
|
||||
export interface CsvColumn {
|
||||
dataKey: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface CsvExportOptions {
|
||||
maxRows?: number;
|
||||
groupColumnName?: string;
|
||||
}
|
||||
|
||||
export interface CsvExportResult {
|
||||
csvData: Record<string, any>[];
|
||||
maxRows: number;
|
||||
isDataEmpty: boolean;
|
||||
actualRowCount: number;
|
||||
isLimited: boolean;
|
||||
}
|
||||
|
||||
const generateCsvData = (
|
||||
data: unknown[],
|
||||
columns: CsvColumn[],
|
||||
options: CsvExportOptions = {},
|
||||
): Record<string, any>[] => {
|
||||
const { groupColumnName } = options;
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
console.warn('CSV Export: data must be an array');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(columns) || columns.length === 0) {
|
||||
console.warn('CSV Export: columns must be a non-empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
const invalidColumns = columns.filter(
|
||||
col =>
|
||||
!col ||
|
||||
typeof col.dataKey !== 'string' ||
|
||||
typeof col.displayName !== 'string',
|
||||
);
|
||||
|
||||
if (invalidColumns.length > 0) {
|
||||
console.warn(
|
||||
'CSV Export: Invalid column structure detected',
|
||||
invalidColumns,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data
|
||||
.filter(
|
||||
(row): row is Record<string, any> =>
|
||||
row != null && typeof row === 'object',
|
||||
)
|
||||
.map((row, index) => {
|
||||
try {
|
||||
return {
|
||||
...(groupColumnName != null
|
||||
? { [groupColumnName]: row.group ?? '' }
|
||||
: {}),
|
||||
...Object.fromEntries(
|
||||
columns.map(({ displayName, dataKey }) => {
|
||||
const value = row[dataKey];
|
||||
|
||||
if (value == null) {
|
||||
return [displayName, ''];
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return [displayName, JSON.stringify(value)];
|
||||
}
|
||||
|
||||
return [displayName, String(value)];
|
||||
}),
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`CSV Export: Error processing row ${index}:`, error);
|
||||
return {};
|
||||
}
|
||||
})
|
||||
.filter(row => Object.keys(row).length > 0);
|
||||
};
|
||||
|
||||
export const useCsvExport = (
|
||||
data: unknown[],
|
||||
columns: CsvColumn[],
|
||||
options: CsvExportOptions = {},
|
||||
): CsvExportResult => {
|
||||
const { maxRows = DEFAULT_MAX_ROWS, groupColumnName } = options;
|
||||
|
||||
const result = useMemo(() => {
|
||||
const isDataEmpty = !Array.isArray(data) || data.length === 0;
|
||||
|
||||
if (isDataEmpty || !Array.isArray(columns) || columns.length === 0) {
|
||||
return {
|
||||
csvData: [],
|
||||
maxRows,
|
||||
isDataEmpty: true,
|
||||
actualRowCount: 0,
|
||||
isLimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
const limitedData = data.slice(0, maxRows);
|
||||
const csvData = generateCsvData(limitedData, columns, { groupColumnName });
|
||||
|
||||
return {
|
||||
csvData,
|
||||
maxRows,
|
||||
isDataEmpty: false,
|
||||
actualRowCount: csvData.length,
|
||||
isLimited: data.length > maxRows,
|
||||
};
|
||||
}, [data, columns, maxRows, groupColumnName]);
|
||||
|
||||
return result;
|
||||
};
|
||||
45
yarn.lock
45
yarn.lock
|
|
@ -4573,7 +4573,6 @@ __metadata:
|
|||
"@types/pluralize": "npm:^0.0.29"
|
||||
"@types/react": "npm:18.3.1"
|
||||
"@types/react-copy-to-clipboard": "npm:^5.0.2"
|
||||
"@types/react-csv": "npm:^1.1.3"
|
||||
"@types/react-dom": "npm:18.3.1"
|
||||
"@types/react-grid-layout": "npm:^1.3.2"
|
||||
"@types/react-syntax-highlighter": "npm:^13.5.2"
|
||||
|
|
@ -4619,7 +4618,6 @@ __metadata:
|
|||
react-bootstrap: "npm:^2.4.0"
|
||||
react-bootstrap-range-slider: "npm:^3.0.8"
|
||||
react-copy-to-clipboard: "npm:^5.1.0"
|
||||
react-csv: "npm:^2.2.2"
|
||||
react-dom: "npm:18.3.1"
|
||||
react-error-boundary: "npm:^3.1.4"
|
||||
react-grid-layout: "npm:^1.3.4"
|
||||
|
|
@ -4629,6 +4627,7 @@ __metadata:
|
|||
react-json-tree: "npm:^0.17.0"
|
||||
react-markdown: "npm:^8.0.4"
|
||||
react-modern-drawer: "npm:^1.2.0"
|
||||
react-papaparse: "npm:^4.4.0"
|
||||
react-query: "npm:^3.39.3"
|
||||
react-select: "npm:^5.7.0"
|
||||
react-sortable-hoc: "npm:^2.0.0"
|
||||
|
|
@ -10341,6 +10340,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/papaparse@npm:^5.3.9":
|
||||
version: 5.3.16
|
||||
resolution: "@types/papaparse@npm:5.3.16"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10c0/0009d3dcdc20cd37f171db77307844a575b89435a3398d1437173a1e5feb13a88e61fed7756f264555743a9510693ae1c9e20c4558c066085fd0bd881abf5497
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/parse-json@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@types/parse-json@npm:4.0.0"
|
||||
|
|
@ -10485,15 +10493,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-csv@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "@types/react-csv@npm:1.1.3"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
checksum: 10c0/747900032b96c404cd542bd7536590e2ac4872a895720bc85601fafe5f51c5b4b24ac615e5e26a08e3d9d01f55057c7a4ecd5f55d1e0ccc91dce43b03a5f6e6e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-dom@npm:18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "@types/react-dom@npm:18.3.1"
|
||||
|
|
@ -23199,6 +23198,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"papaparse@npm:^5.4.1":
|
||||
version: 5.5.3
|
||||
resolution: "papaparse@npm:5.5.3"
|
||||
checksum: 10c0/623aae6a35703308fd5a39d616fb3837231ebc70697346355ea508154d3f24df75b6554b736afc1924192205518a5db14e75f5e1cf35d154326050a37cdd9447
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"param-case@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "param-case@npm:3.0.4"
|
||||
|
|
@ -24576,13 +24582,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-csv@npm:^2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "react-csv@npm:2.2.2"
|
||||
checksum: 10c0/287e7ba2085a32a0e65d7a19b9e063e43f06f75ab6ebc18bb52b518007a1d6ec5f5ce33fc7c4e290e3ea41cfb251657b6e62aadadc8d7ffcef7e53d43bf3eb69
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-docgen-typescript@npm:^2.2.2":
|
||||
version: 2.2.2
|
||||
resolution: "react-docgen-typescript@npm:2.2.2"
|
||||
|
|
@ -24813,6 +24812,16 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-papaparse@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "react-papaparse@npm:4.4.0"
|
||||
dependencies:
|
||||
"@types/papaparse": "npm:^5.3.9"
|
||||
papaparse: "npm:^5.4.1"
|
||||
checksum: 10c0/b574beee6d1b1dd409ee9b744244c9e43fc4eea4b495a228c3f2bf4fe7059aa899582e3deef9a1df1a456ad0854116d1ec42d60f0791775d6375f8d2e251985f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-query@npm:^3.39.3":
|
||||
version: 3.39.3
|
||||
resolution: "react-query@npm:3.39.3"
|
||||
|
|
|
|||
Loading…
Reference in a new issue