mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Discover] Format JSON messages in Observability Logs profile (#205666)
## Summary This PR updates the Observability Logs profile to detect and auto format JSON message values within both the Log overview doc viewer tab and the Summary cell popover. Additionally, it enables CTRL/CMD + F find functionality within the doc viewer JSON tab for all contexts to make it easier for users to search the JSON output. JSON message formatting:  JSON tab find functionality:  ### Checklist - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
d96168c64f
commit
518e0afbde
11 changed files with 192 additions and 92 deletions
|
@ -10,7 +10,12 @@
|
|||
import React from 'react';
|
||||
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SummaryColumn, { SummaryColumnFactoryDeps, SummaryColumnProps } from './summary_column';
|
||||
import SummaryColumn, {
|
||||
AllSummaryColumnProps,
|
||||
SummaryCellPopover,
|
||||
SummaryColumnFactoryDeps,
|
||||
SummaryColumnProps,
|
||||
} from './summary_column';
|
||||
import { DataGridDensity, ROWS_HEIGHT_OPTIONS } from '@kbn/unified-data-table';
|
||||
import * as constants from '@kbn/discover-utils/src/data_types/logs/constants';
|
||||
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
|
||||
|
@ -18,32 +23,46 @@ import { coreMock as corePluginMock } from '@kbn/core/public/mocks';
|
|||
import { DataTableRecord, buildDataTableRecord } from '@kbn/discover-utils';
|
||||
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__/data_view';
|
||||
|
||||
jest.mock('@elastic/eui', () => ({
|
||||
...jest.requireActual('@elastic/eui'),
|
||||
EuiCodeBlock: ({
|
||||
children,
|
||||
dangerouslySetInnerHTML,
|
||||
}: {
|
||||
children?: string;
|
||||
dangerouslySetInnerHTML?: { __html: string };
|
||||
}) => <code data-test-subj="codeBlock">{children ?? dangerouslySetInnerHTML?.__html ?? ''}</code>,
|
||||
}));
|
||||
|
||||
const getSummaryProps = (
|
||||
record: DataTableRecord,
|
||||
opts: Partial<SummaryColumnProps & SummaryColumnFactoryDeps> = {}
|
||||
): AllSummaryColumnProps => ({
|
||||
rowIndex: 0,
|
||||
colIndex: 0,
|
||||
columnId: '_source',
|
||||
isExpandable: true,
|
||||
isExpanded: false,
|
||||
isDetails: false,
|
||||
row: record,
|
||||
dataView: dataViewMock,
|
||||
fieldFormats: fieldFormatsMock,
|
||||
setCellProps: () => {},
|
||||
closePopover: () => {},
|
||||
density: DataGridDensity.COMPACT,
|
||||
rowHeight: ROWS_HEIGHT_OPTIONS.single,
|
||||
onFilter: jest.fn(),
|
||||
shouldShowFieldHandler: () => true,
|
||||
core: corePluginMock.createStart(),
|
||||
share: sharePluginMock.createStartContract(),
|
||||
...opts,
|
||||
});
|
||||
|
||||
const renderSummary = (
|
||||
record: DataTableRecord,
|
||||
opts: Partial<SummaryColumnProps & SummaryColumnFactoryDeps> = {}
|
||||
) => {
|
||||
render(
|
||||
<SummaryColumn
|
||||
rowIndex={0}
|
||||
colIndex={0}
|
||||
columnId="_source"
|
||||
isExpandable={true}
|
||||
isExpanded={false}
|
||||
isDetails={false}
|
||||
row={record}
|
||||
dataView={dataViewMock}
|
||||
fieldFormats={fieldFormatsMock}
|
||||
setCellProps={() => {}}
|
||||
closePopover={() => {}}
|
||||
density={DataGridDensity.COMPACT}
|
||||
rowHeight={ROWS_HEIGHT_OPTIONS.single}
|
||||
onFilter={jest.fn()}
|
||||
shouldShowFieldHandler={() => true}
|
||||
core={corePluginMock.createStart()}
|
||||
share={sharePluginMock.createStartContract()}
|
||||
{...opts}
|
||||
/>
|
||||
);
|
||||
render(<SummaryColumn {...getSummaryProps(record, opts)} />);
|
||||
};
|
||||
|
||||
const getBaseRecord = (overrides: Record<string, unknown> = {}) =>
|
||||
|
@ -174,3 +193,18 @@ describe('SummaryColumn', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SummaryCellPopover', () => {
|
||||
it('should render message value', async () => {
|
||||
const message = 'This is a message';
|
||||
render(<SummaryCellPopover {...getSummaryProps(getBaseRecord({ message }))} />);
|
||||
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(message);
|
||||
});
|
||||
|
||||
it('should render formatted JSON message value', async () => {
|
||||
const json = { foo: { bar: true } };
|
||||
const message = JSON.stringify(json);
|
||||
render(<SummaryCellPopover {...getSummaryProps(getBaseRecord({ message }))} />);
|
||||
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(JSON.stringify(json, null, 2));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -98,14 +98,19 @@ const SummaryCell = ({
|
|||
);
|
||||
};
|
||||
|
||||
const SummaryCellPopover = (props: AllSummaryColumnProps) => {
|
||||
export const SummaryCellPopover = (props: AllSummaryColumnProps) => {
|
||||
const { row, dataView, fieldFormats, onFilter, closePopover, share, core } = props;
|
||||
|
||||
const resourceFields = createResourceFields(row, core, share);
|
||||
const shouldRenderResource = resourceFields.length > 0;
|
||||
|
||||
const documentOverview = getLogDocumentOverview(row, { dataView, fieldFormats });
|
||||
const { field, value } = getMessageFieldWithFallbacks(documentOverview);
|
||||
const { field, value, formattedValue } = getMessageFieldWithFallbacks(documentOverview, {
|
||||
includeFormattedValue: true,
|
||||
});
|
||||
const messageCodeBlockProps = formattedValue
|
||||
? { language: 'json', children: formattedValue }
|
||||
: { language: 'txt', dangerouslySetInnerHTML: { __html: value ?? '' } };
|
||||
const shouldRenderContent = Boolean(field && value);
|
||||
|
||||
const shouldRenderSource = !shouldRenderContent;
|
||||
|
@ -142,11 +147,9 @@ const SummaryCellPopover = (props: AllSummaryColumnProps) => {
|
|||
overflowHeight={100}
|
||||
paddingSize="s"
|
||||
isCopyable
|
||||
language="txt"
|
||||
fontSize="s"
|
||||
>
|
||||
{value}
|
||||
</EuiCodeBlock>
|
||||
{...messageCodeBlockProps}
|
||||
/>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
{shouldRenderSource && (
|
||||
|
|
|
@ -7,10 +7,14 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { unescape } from 'lodash';
|
||||
import { fieldConstants } from '..';
|
||||
import { LogDocumentOverview } from '../types';
|
||||
|
||||
export const getMessageFieldWithFallbacks = (doc: LogDocumentOverview) => {
|
||||
export const getMessageFieldWithFallbacks = (
|
||||
doc: LogDocumentOverview,
|
||||
{ includeFormattedValue = false }: { includeFormattedValue?: boolean } = {}
|
||||
) => {
|
||||
const rankingOrder = [
|
||||
fieldConstants.MESSAGE_FIELD,
|
||||
fieldConstants.ERROR_MESSAGE_FIELD,
|
||||
|
@ -18,8 +22,20 @@ export const getMessageFieldWithFallbacks = (doc: LogDocumentOverview) => {
|
|||
] as const;
|
||||
|
||||
for (const rank of rankingOrder) {
|
||||
if (doc[rank] !== undefined && doc[rank] !== null) {
|
||||
return { field: rank, value: doc[rank] };
|
||||
const value = doc[rank];
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
let formattedValue: string | undefined;
|
||||
|
||||
if (includeFormattedValue) {
|
||||
try {
|
||||
formattedValue = JSON.stringify(JSON.parse(unescape(value)), null, 2);
|
||||
} catch {
|
||||
// If the value is not a valid JSON, leave it unformatted
|
||||
}
|
||||
}
|
||||
|
||||
return { field: rank, value, formattedValue };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,17 @@ import { setUnifiedDocViewerServices } from '../../plugin';
|
|||
import { mockUnifiedDocViewerServices } from '../../__mocks__';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
jest.mock('@elastic/eui', () => ({
|
||||
...jest.requireActual('@elastic/eui'),
|
||||
EuiCodeBlock: ({
|
||||
children,
|
||||
dangerouslySetInnerHTML,
|
||||
}: {
|
||||
children?: string;
|
||||
dangerouslySetInnerHTML?: { __html: string };
|
||||
}) => <code data-test-subj="codeBlock">{children ?? dangerouslySetInnerHTML?.__html ?? ''}</code>,
|
||||
}));
|
||||
|
||||
const DATASET_NAME = 'logs.overview';
|
||||
const NAMESPACE = 'default';
|
||||
const DATA_STREAM_NAME = `logs-${DATASET_NAME}-${NAMESPACE}`;
|
||||
|
@ -59,51 +70,55 @@ dataView.fields.getByName = (name: string) => {
|
|||
return dataView.fields.getAll().find((field) => field.name === name);
|
||||
};
|
||||
|
||||
const fullHit = buildDataTableRecord(
|
||||
{
|
||||
_index: DATA_STREAM_NAME,
|
||||
_id: DATA_STREAM_NAME,
|
||||
_score: 1,
|
||||
_source: {
|
||||
'@timestamp': NOW + 1000,
|
||||
message: 'full document',
|
||||
log: { level: 'info', file: { path: '/logs.overview.log' } },
|
||||
data_stream: {
|
||||
type: 'logs',
|
||||
dataset: DATASET_NAME,
|
||||
namespace: NAMESPACE,
|
||||
const buildHit = (fields?: Record<string, unknown>) =>
|
||||
buildDataTableRecord(
|
||||
{
|
||||
_index: DATA_STREAM_NAME,
|
||||
_id: DATA_STREAM_NAME,
|
||||
_score: 1,
|
||||
_source: {
|
||||
'@timestamp': NOW + 1000,
|
||||
message: 'full document',
|
||||
log: { level: 'info', file: { path: '/logs.overview.log' } },
|
||||
data_stream: {
|
||||
type: 'logs',
|
||||
dataset: DATASET_NAME,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
'service.name': DATASET_NAME,
|
||||
'host.name': 'gke-edge-oblt-pool',
|
||||
'trace.id': 'abcdef',
|
||||
orchestrator: {
|
||||
cluster: {
|
||||
id: 'my-cluster-id',
|
||||
name: 'my-cluster-name',
|
||||
},
|
||||
resource: {
|
||||
id: 'orchestratorResourceId',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
provider: ['gcp'],
|
||||
region: 'us-central-1',
|
||||
availability_zone: MORE_THAN_1024_CHARS,
|
||||
project: {
|
||||
id: 'elastic-project',
|
||||
},
|
||||
instance: {
|
||||
id: 'BgfderflkjTheUiGuy',
|
||||
},
|
||||
},
|
||||
'agent.name': 'node',
|
||||
...fields,
|
||||
},
|
||||
'service.name': DATASET_NAME,
|
||||
'host.name': 'gke-edge-oblt-pool',
|
||||
'trace.id': 'abcdef',
|
||||
orchestrator: {
|
||||
cluster: {
|
||||
id: 'my-cluster-id',
|
||||
name: 'my-cluster-name',
|
||||
},
|
||||
resource: {
|
||||
id: 'orchestratorResourceId',
|
||||
},
|
||||
ignored_field_values: {
|
||||
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
|
||||
},
|
||||
cloud: {
|
||||
provider: ['gcp'],
|
||||
region: 'us-central-1',
|
||||
availability_zone: MORE_THAN_1024_CHARS,
|
||||
project: {
|
||||
id: 'elastic-project',
|
||||
},
|
||||
instance: {
|
||||
id: 'BgfderflkjTheUiGuy',
|
||||
},
|
||||
},
|
||||
'agent.name': 'node',
|
||||
},
|
||||
ignored_field_values: {
|
||||
'cloud.availability_zone': [MORE_THAN_1024_CHARS],
|
||||
},
|
||||
},
|
||||
dataView
|
||||
);
|
||||
dataView
|
||||
);
|
||||
|
||||
const fullHit = buildHit();
|
||||
|
||||
const getCustomUnifedDocViewerServices = (params?: {
|
||||
showApm: boolean;
|
||||
|
@ -313,3 +328,18 @@ describe('LogsOverview with APM links', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LogsOverview content breakdown', () => {
|
||||
it('should render message value', async () => {
|
||||
const message = 'This is a message';
|
||||
renderLogsOverview({ hit: buildHit({ message }) });
|
||||
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(message);
|
||||
});
|
||||
|
||||
it('should render formatted JSON message value', async () => {
|
||||
const json = { foo: { bar: true } };
|
||||
const message = JSON.stringify(json);
|
||||
renderLogsOverview({ hit: buildHit({ message }) });
|
||||
expect(screen.queryByTestId('codeBlock')?.innerHTML).toBe(JSON.stringify(json, null, 2));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -34,7 +34,12 @@ export const contentLabel = i18n.translate('unifiedDocViewer.docView.logsOvervie
|
|||
export function LogsOverviewHeader({ doc }: { doc: LogDocumentOverview }) {
|
||||
const hasLogLevel = Boolean(doc[fieldConstants.LOG_LEVEL_FIELD]);
|
||||
const hasTimestamp = Boolean(doc[fieldConstants.TIMESTAMP_FIELD]);
|
||||
const { field, value } = getMessageFieldWithFallbacks(doc);
|
||||
const { field, value, formattedValue } = getMessageFieldWithFallbacks(doc, {
|
||||
includeFormattedValue: true,
|
||||
});
|
||||
const messageCodeBlockProps = formattedValue
|
||||
? { language: 'json', children: formattedValue }
|
||||
: { language: 'txt', dangerouslySetInnerHTML: { __html: value ?? '' } };
|
||||
const hasBadges = hasTimestamp || hasLogLevel;
|
||||
const hasMessageField = field && value;
|
||||
const hasFlyoutHeader = hasMessageField || hasBadges;
|
||||
|
@ -80,14 +85,19 @@ export function LogsOverviewHeader({ doc }: { doc: LogDocumentOverview }) {
|
|||
</EuiText>
|
||||
<EuiFlexItem grow={false}>{logLevelAndTimestamp}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<HoverActionPopover value={value} field={field} anchorPosition="downCenter" display="block">
|
||||
<HoverActionPopover
|
||||
value={value}
|
||||
formattedValue={formattedValue}
|
||||
field={field}
|
||||
anchorPosition="downCenter"
|
||||
display="block"
|
||||
>
|
||||
<EuiCodeBlock
|
||||
overflowHeight={100}
|
||||
paddingSize="s"
|
||||
isCopyable
|
||||
language="txt"
|
||||
fontSize="s"
|
||||
dangerouslySetInnerHTML={{ __html: value }}
|
||||
{...messageCodeBlockProps}
|
||||
/>
|
||||
</HoverActionPopover>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -23,6 +23,7 @@ interface HoverPopoverActionProps {
|
|||
children: React.ReactChild;
|
||||
field: string;
|
||||
value: unknown;
|
||||
formattedValue?: string;
|
||||
title?: unknown;
|
||||
anchorPosition?: PopoverAnchorPosition;
|
||||
display?: EuiPopoverProps['display'];
|
||||
|
@ -33,12 +34,13 @@ export const HoverActionPopover = ({
|
|||
title,
|
||||
field,
|
||||
value,
|
||||
formattedValue,
|
||||
anchorPosition = 'upCenter',
|
||||
display = 'inline-block',
|
||||
}: HoverPopoverActionProps) => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const leaveTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const uiFieldActions = useUIFieldActions({ field, value });
|
||||
const uiFieldActions = useUIFieldActions({ field, value, formattedValue });
|
||||
|
||||
// The timeout hack is required because we are using a Popover which ideally should be used with a mouseclick,
|
||||
// but we are using it as a Tooltip. Which means we now need to manually handle the open and close
|
||||
|
|
|
@ -31,7 +31,6 @@ describe('Source Viewer component', () => {
|
|||
index={'index1'}
|
||||
dataView={mockDataView}
|
||||
width={123}
|
||||
hasLineNumbers={true}
|
||||
onRefresh={() => {}}
|
||||
/>
|
||||
);
|
||||
|
@ -48,7 +47,6 @@ describe('Source Viewer component', () => {
|
|||
index={'index1'}
|
||||
dataView={mockDataView}
|
||||
width={123}
|
||||
hasLineNumbers={true}
|
||||
onRefresh={() => {}}
|
||||
/>
|
||||
);
|
||||
|
@ -86,7 +84,6 @@ describe('Source Viewer component', () => {
|
|||
index={'index1'}
|
||||
dataView={mockDataView}
|
||||
width={123}
|
||||
hasLineNumbers={true}
|
||||
onRefresh={() => {}}
|
||||
/>
|
||||
);
|
||||
|
@ -94,5 +91,7 @@ describe('Source Viewer component', () => {
|
|||
expect(jsonCodeEditor).not.toBe(null);
|
||||
expect(jsonCodeEditor.props().jsonValue).toContain('_source');
|
||||
expect(jsonCodeEditor.props().jsonValue).not.toContain('_score');
|
||||
expect(jsonCodeEditor.props().hasLineNumbers).toBe(true);
|
||||
expect(jsonCodeEditor.props().enableFindAction).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,10 +27,8 @@ interface SourceViewerProps {
|
|||
index: string | undefined;
|
||||
dataView: DataView;
|
||||
textBasedHits?: DataTableRecord[];
|
||||
hasLineNumbers: boolean;
|
||||
width?: number;
|
||||
decreaseAvailableHeightBy?: number;
|
||||
requestState?: ElasticRequestState;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
|
@ -41,9 +39,8 @@ export const DocViewerSource = ({
|
|||
id,
|
||||
index,
|
||||
dataView,
|
||||
width,
|
||||
hasLineNumbers,
|
||||
textBasedHits,
|
||||
width,
|
||||
decreaseAvailableHeightBy,
|
||||
onRefresh,
|
||||
}: SourceViewerProps) => {
|
||||
|
@ -132,7 +129,8 @@ export const DocViewerSource = ({
|
|||
jsonValue={jsonValue}
|
||||
width={width}
|
||||
height={editorHeight}
|
||||
hasLineNumbers={hasLineNumbers}
|
||||
hasLineNumbers
|
||||
enableFindAction
|
||||
onEditorDidMount={(editorNode: monaco.editor.IStandaloneCodeEditor) => setEditor(editorNode)}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -29,6 +29,7 @@ interface JsonCodeEditorCommonProps {
|
|||
height?: string | number;
|
||||
hasLineNumbers?: boolean;
|
||||
hideCopyButton?: boolean;
|
||||
enableFindAction?: boolean;
|
||||
}
|
||||
|
||||
export const JsonCodeEditorCommon = ({
|
||||
|
@ -38,6 +39,7 @@ export const JsonCodeEditorCommon = ({
|
|||
hasLineNumbers,
|
||||
onEditorDidMount,
|
||||
hideCopyButton,
|
||||
enableFindAction,
|
||||
}: JsonCodeEditorCommonProps) => {
|
||||
if (jsonValue === '') {
|
||||
return null;
|
||||
|
@ -66,6 +68,7 @@ export const JsonCodeEditorCommon = ({
|
|||
wordWrap: 'on',
|
||||
wrappingIndent: 'indent',
|
||||
}}
|
||||
enableFindAction={enableFindAction}
|
||||
/>
|
||||
);
|
||||
if (hideCopyButton) {
|
||||
|
|
|
@ -21,7 +21,9 @@ interface WithValueParam {
|
|||
value: unknown;
|
||||
}
|
||||
|
||||
interface TFieldActionParams extends WithFieldParam, WithValueParam {}
|
||||
interface TFieldActionParams extends WithFieldParam, WithValueParam {
|
||||
formattedValue?: string;
|
||||
}
|
||||
|
||||
export interface TFieldAction {
|
||||
id: string;
|
||||
|
@ -66,7 +68,11 @@ export const [FieldActionsProvider, useFieldActionsContext] = createContainer(us
|
|||
/**
|
||||
* This is a preset of the UI elements and related actions that can be used to build an action bar anywhere in a DocView
|
||||
*/
|
||||
export const useUIFieldActions = ({ field, value }: TFieldActionParams): TFieldAction[] => {
|
||||
export const useUIFieldActions = ({
|
||||
field,
|
||||
value,
|
||||
formattedValue,
|
||||
}: TFieldActionParams): TFieldAction[] => {
|
||||
const actions = useFieldActionsContext();
|
||||
|
||||
return useMemo(
|
||||
|
@ -99,10 +105,10 @@ export const useUIFieldActions = ({ field, value }: TFieldActionParams): TFieldA
|
|||
id: 'copyToClipboardAction',
|
||||
iconType: 'copyClipboard',
|
||||
label: copyToClipboardLabel,
|
||||
onClick: () => actions.copyToClipboard(value as string),
|
||||
onClick: () => actions.copyToClipboard(formattedValue ?? (value as string)),
|
||||
},
|
||||
],
|
||||
[actions, field, value]
|
||||
[actions, field, formattedValue, value]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -78,7 +78,6 @@ export class UnifiedDocViewerPublicPlugin
|
|||
id={hit.raw._id ?? hit.id}
|
||||
dataView={dataView}
|
||||
textBasedHits={textBasedHits}
|
||||
hasLineNumbers
|
||||
decreaseAvailableHeightBy={decreaseAvailableHeightBy}
|
||||
onRefresh={() => {}}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue