mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-10 08:30:33 +01:00
Compare commits
2 Commits
feat/expor
...
feat/expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851ecba738 | ||
|
|
02a0d2e21f |
@@ -1,4 +1,4 @@
|
||||
.timeseries-export-popover {
|
||||
.export-menu-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
background-color: var(--l2-background);
|
||||
@@ -2,42 +2,43 @@ import { Download, LoaderCircle } from '@signozhq/icons';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Button, Popover, Tooltip } from 'antd';
|
||||
import { useClientExport } from 'hooks/useExportData/useClientExport';
|
||||
import {
|
||||
ClientExportData,
|
||||
useClientExport,
|
||||
} from 'hooks/useExportData/useClientExport';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import './TimeseriesExportMenu.styles.scss';
|
||||
import './ExportMenu.styles.scss';
|
||||
|
||||
interface TimeseriesExportMenuProps {
|
||||
interface ExportMenuProps {
|
||||
dataSource: DataSource;
|
||||
queryResponse: QueryRangeResponseV5;
|
||||
// The queryRange response object the view holds — the hook picks the
|
||||
// serializer (timeseries / table) from what it carries.
|
||||
data: ClientExportData;
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
// Download menu for in-memory timeseries data (client-side serialization).
|
||||
// Download menu for in-memory query results (client-side serialization).
|
||||
// The raw/list backend export keeps its own menu in DownloadOptionsMenu.
|
||||
export default function TimeseriesExportMenu({
|
||||
export default function ExportMenu({
|
||||
dataSource,
|
||||
queryResponse,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
}: TimeseriesExportMenuProps): JSX.Element {
|
||||
}: ExportMenuProps): JSX.Element {
|
||||
const [exportFormat, setExportFormat] = useState<string>(ExportFormat.Csv);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const { isExporting, handleExport: handleClientExport } = useClientExport({
|
||||
response: queryResponse,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
fileName,
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ export default function TimeseriesExportMenu({
|
||||
arrow={false}
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
rootClassName="timeseries-export-popover"
|
||||
rootClassName="export-menu-popover"
|
||||
>
|
||||
<Tooltip title="Download" placement="top">
|
||||
<Button
|
||||
@@ -97,7 +98,7 @@ export default function TimeseriesExportMenu({
|
||||
<Download size={14} />
|
||||
)
|
||||
}
|
||||
data-testid={`timeseries-export-${dataSource}`}
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TimeseriesExportMenu from '../TimeseriesExportMenu';
|
||||
import ExportMenu from '../ExportMenu';
|
||||
|
||||
const mockHandleExport = jest.fn();
|
||||
let mockIsExporting = false;
|
||||
@@ -14,25 +14,26 @@ jest.mock('hooks/useExportData/useClientExport', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const response = {
|
||||
type: 'time_series',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const data = {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: 'time_series' } },
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
|
||||
const TEST_ID = `timeseries-export-${DataSource.LOGS}`;
|
||||
const TEST_ID = `export-menu-${DataSource.LOGS}`;
|
||||
|
||||
function renderMenu(): void {
|
||||
render(
|
||||
<TimeseriesExportMenu
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
queryResponse={response}
|
||||
data={data}
|
||||
fileName="logs-timeseries"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('TimeseriesExportMenu', () => {
|
||||
describe('ExportMenu', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleExport.mockReset();
|
||||
mockIsExporting = false;
|
||||
@@ -182,6 +182,14 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: visible;
|
||||
|
||||
.table-view-container-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.time-series-view-container {
|
||||
|
||||
@@ -18,13 +18,18 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialFilters, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialFilters,
|
||||
initialQueriesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
|
||||
import GoToTop from 'container/GoToTop';
|
||||
import LogsExplorerChart from 'container/LogsExplorerChart';
|
||||
import LogsExplorerList from 'container/LogsExplorerList';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import LogsExplorerTable from 'container/LogsExplorerTable';
|
||||
import {
|
||||
getExportQueryData,
|
||||
@@ -476,6 +481,16 @@ function LogsExplorerViewsContainer({
|
||||
)}
|
||||
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (
|
||||
<div className="table-view-container">
|
||||
{data && !isError && (
|
||||
<div className="table-view-container-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.LOGS}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.metrics}
|
||||
fileName="logs-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<LogsExplorerTable
|
||||
data={
|
||||
(data?.payload?.data?.newResult?.data?.result ||
|
||||
|
||||
@@ -48,7 +48,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeRange } from 'utils/getTimeRange';
|
||||
|
||||
import TimeseriesExportMenu from './TimeseriesExportMenu';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
@@ -265,12 +265,11 @@ function TimeSeriesView({
|
||||
)}
|
||||
</div>
|
||||
{showExport && data?.rawV5Response && (
|
||||
<TimeseriesExportMenu
|
||||
<ExportMenu
|
||||
dataSource={dataSource}
|
||||
yAxisUnit={yAxisUnit}
|
||||
queryResponse={data.rawV5Response}
|
||||
data={data}
|
||||
query={currentQuery}
|
||||
legendMap={data.legendMap}
|
||||
fileName={`${dataSource}-timeseries`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,7 @@ jest.mock('components/Uplot', () => ({
|
||||
default: (): JSX.Element => <div data-testid="uplot-chart" />,
|
||||
}));
|
||||
|
||||
jest.mock('../TimeseriesExportMenu', () => ({
|
||||
jest.mock('components/ExportMenu/ExportMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="timeseries-export-menu" />,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
@@ -20,8 +21,11 @@ import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
@@ -97,6 +101,16 @@ function TableView({
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { downloadFile } from 'lib/exportData/downloadFile';
|
||||
import { ExportFormat } from 'lib/exportData/types';
|
||||
import { QueryRangeResponseV5 } from 'types/api/v5/queryRange';
|
||||
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useClientExport } from '../useClientExport';
|
||||
|
||||
@@ -21,43 +22,95 @@ jest.mock('antd', () => {
|
||||
|
||||
const mockDownloadFile = downloadFile as jest.Mock;
|
||||
|
||||
function timeSeriesResponse(): QueryRangeResponseV5 {
|
||||
const query = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
function timeSeriesData(): MetricQueryRangeSuccessResponse {
|
||||
return {
|
||||
type: 'time_series',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'a' }],
|
||||
values: [{ timestamp: 1000, value: 12 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: 'time_series' } },
|
||||
legendMap: { A: '{{service}}' },
|
||||
rawV5Response: {
|
||||
type: 'time_series',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
aggregations: [
|
||||
{
|
||||
index: 0,
|
||||
alias: '',
|
||||
meta: {},
|
||||
series: [
|
||||
{
|
||||
labels: [{ key: { name: 'service' }, value: 'a' }],
|
||||
values: [{ timestamp: 1000, value: 12 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {},
|
||||
},
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
}
|
||||
|
||||
function scalarData(): MetricQueryRangeSuccessResponse {
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: {
|
||||
data: {
|
||||
resultType: 'scalar',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
series: null,
|
||||
list: null,
|
||||
table: {
|
||||
columns: [
|
||||
{
|
||||
name: 'service.name',
|
||||
id: 'service.name',
|
||||
queryName: 'A',
|
||||
isValueColumn: false,
|
||||
},
|
||||
{ name: 'count()', id: 'A', queryName: 'A', isValueColumn: true },
|
||||
],
|
||||
rows: [{ data: { 'service.name': 'frontend', A: 120 } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
}
|
||||
|
||||
describe('useClientExport', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('exports time_series as CSV to a timestamped <fileName>.csv', () => {
|
||||
it('dispatches timeseries data to the timeseries serializer (csv)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({
|
||||
response: timeSeriesResponse(),
|
||||
fileName: 'chart',
|
||||
legendMap: { A: '{{service}}' },
|
||||
}),
|
||||
useClientExport({ data: timeSeriesData(), query, fileName: 'chart' }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -69,12 +122,28 @@ describe('useClientExport', () => {
|
||||
expect(name).toMatch(/^chart-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.csv$/);
|
||||
expect(mime).toContain('text/csv');
|
||||
expect(content).toContain('service');
|
||||
expect(content).toContain('a');
|
||||
});
|
||||
|
||||
it('exports as JSONL to a timestamped <fileName>.jsonl with the ndjson mime', () => {
|
||||
it('dispatches scalar (table) data to the table serializer', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ response: timeSeriesResponse() }),
|
||||
useClientExport({ data: scalarData(), query, fileName: 'table' }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
});
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledTimes(1);
|
||||
const [content, name] = mockDownloadFile.mock.calls[0];
|
||||
expect(name).toMatch(/^table-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.csv$/);
|
||||
expect(content).toContain('service.name');
|
||||
expect(content).toContain('frontend');
|
||||
expect(content).toContain('120');
|
||||
});
|
||||
|
||||
it('exports as JSONL with the ndjson mime', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useClientExport({ data: timeSeriesData(), query }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -87,8 +156,8 @@ describe('useClientExport', () => {
|
||||
expect(content).toContain('"series"');
|
||||
});
|
||||
|
||||
it('does nothing when there is no response', () => {
|
||||
const { result } = renderHook(() => useClientExport({}));
|
||||
it('does nothing when there is no data', () => {
|
||||
const { result } = renderHook(() => useClientExport({ query }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
@@ -98,13 +167,14 @@ describe('useClientExport', () => {
|
||||
expect(mockMessageError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and does not download for unsupported result types', () => {
|
||||
it('shows an error for unsupported result types', () => {
|
||||
const raw = {
|
||||
type: 'raw',
|
||||
data: { results: [] },
|
||||
meta: {},
|
||||
} as unknown as QueryRangeResponseV5;
|
||||
const { result } = renderHook(() => useClientExport({ response: raw }));
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: '',
|
||||
payload: { data: { result: [], resultType: '' } },
|
||||
} as unknown as MetricQueryRangeSuccessResponse;
|
||||
const { result } = renderHook(() => useClientExport({ data: raw, query }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleExport({ format: ExportFormat.Csv });
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
downloadFile,
|
||||
getTimestampedFileName,
|
||||
} from 'lib/exportData/downloadFile';
|
||||
import { exportScalarData } from 'lib/exportData/exportScalarData';
|
||||
import { exportTimeseriesData } from 'lib/exportData/exportTimeseriesData';
|
||||
import { toCsv } from 'lib/exportData/toCsv';
|
||||
import { toJsonl } from 'lib/exportData/toJsonl';
|
||||
import { ExportFormat, SerializedTable } from 'lib/exportData/types';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryRangeResponseV5, TimeSeriesData } from 'types/api/v5/queryRange';
|
||||
|
||||
@@ -19,35 +22,47 @@ const FORMAT_META: Record<ExportFormat, { mime: string; extension: string }> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Picks the serializer for the response's request type. Narrows the results
|
||||
// union via the response discriminant. scalar lands with #5591; raw/trace are
|
||||
// server-exported, distribution is never emitted.
|
||||
/** The queryRange response object views hold — structural (params left
|
||||
* unconstrained) so both explorer variants assign cleanly. */
|
||||
export type ClientExportData = SuccessResponse<MetricRangePayloadProps> & {
|
||||
rawV5Response?: QueryRangeResponseV5;
|
||||
legendMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
// Picks the serializer from what the queryRange response carries: timeseries
|
||||
// queries surface the raw V5 tree (rawV5Response); table queries carry the
|
||||
// formatForWeb webTables payload (resultType 'scalar'). raw/trace stay
|
||||
// server-exported via useServerExport.
|
||||
function serialize(
|
||||
response: QueryRangeResponseV5,
|
||||
data: ClientExportData,
|
||||
yAxisUnit?: string,
|
||||
legendMap?: Record<string, string>,
|
||||
query?: Query,
|
||||
): SerializedTable {
|
||||
if (response.type === 'time_series') {
|
||||
if (data.rawV5Response?.type === 'time_series') {
|
||||
return exportTimeseriesData({
|
||||
data: response.data.results as TimeSeriesData[],
|
||||
data: data.rawV5Response.data.results as TimeSeriesData[],
|
||||
yAxisUnit,
|
||||
legendMap,
|
||||
legendMap: data.legendMap,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Export is not supported for "${response.type}" results`);
|
||||
if (data.payload?.data?.resultType === 'scalar' && query) {
|
||||
return exportScalarData({ data, query });
|
||||
}
|
||||
|
||||
throw new Error('Export is not supported for this result type');
|
||||
}
|
||||
|
||||
interface UseClientExportProps {
|
||||
response?: QueryRangeResponseV5;
|
||||
// The builder query behind the response — series names resolve aggregation
|
||||
// aliases/expressions from it, exactly like the chart legend.
|
||||
// The queryRange response object the view already holds — the hook picks
|
||||
// the serializer from what it carries.
|
||||
data?: ClientExportData;
|
||||
// The builder query behind the response — series/column names resolve
|
||||
// aggregation aliases/expressions from it, exactly like the chart does.
|
||||
query?: Query;
|
||||
yAxisUnit?: string;
|
||||
fileName?: string;
|
||||
legendMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ClientExportOptions {
|
||||
@@ -62,23 +77,22 @@ interface UseClientExportReturn {
|
||||
// Frontend-driven export: serializes in-memory query results and downloads them
|
||||
// client-side. Backend-driven export lives in useServerExport.
|
||||
export function useClientExport({
|
||||
response,
|
||||
data,
|
||||
query,
|
||||
yAxisUnit,
|
||||
fileName = 'export',
|
||||
legendMap,
|
||||
}: UseClientExportProps): UseClientExportReturn {
|
||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||
|
||||
const handleExport = useCallback(
|
||||
({ format }: ClientExportOptions): void => {
|
||||
if (!response) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const table = serialize(response, yAxisUnit, legendMap, query);
|
||||
const table = serialize(data, yAxisUnit, query);
|
||||
const content =
|
||||
format === ExportFormat.Jsonl ? toJsonl(table) : toCsv(table);
|
||||
const { mime, extension } = FORMAT_META[format];
|
||||
@@ -89,7 +103,7 @@ export function useClientExport({
|
||||
setIsExporting(false);
|
||||
}
|
||||
},
|
||||
[response, query, yAxisUnit, fileName, legendMap],
|
||||
[data, query, yAxisUnit, fileName],
|
||||
);
|
||||
|
||||
return { isExporting, handleExport };
|
||||
|
||||
Reference in New Issue
Block a user