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.

![image](https://github.com/user-attachments/assets/f6c71d0e-951e-4cc8-a2af-489c03a53598)


Fixes: HDX-1590
This commit is contained in:
Tom Alexander 2025-06-26 10:31:53 -04:00 committed by GitHub
parent b75d7c0595
commit a06c8cdb9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 626 additions and 85 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
feat: Add download csv functionality to search tables

View file

@ -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",

View file

@ -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>

View 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>
);
};

View file

@ -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"

View 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);
});
});
});

View 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;
};

View file

@ -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"