mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Inspector][Lens] Add multitable support to Inspector (#94077)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
81da4f79e1
commit
08f3fe7ef5
8 changed files with 3095 additions and 150 deletions
File diff suppressed because it is too large
Load diff
|
@ -13,16 +13,13 @@ import {
|
|||
EuiButtonIcon,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
// @ts-ignore
|
||||
EuiInMemoryTable,
|
||||
EuiSpacer,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { DataDownloadOptions } from './download_options';
|
||||
import { DataViewRow, DataViewColumn } from '../types';
|
||||
import { IUiSettingsClient } from '../../../../../../core/public';
|
||||
import { Datatable, DatatableColumn } from '../../../../../expressions/public';
|
||||
|
@ -36,7 +33,6 @@ interface DataTableFormatState {
|
|||
|
||||
interface DataTableFormatProps {
|
||||
data: Datatable;
|
||||
exportTitle: string;
|
||||
uiSettings: IUiSettingsClient;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
uiActions: UiActionsStart;
|
||||
|
@ -55,7 +51,6 @@ interface RenderCellArguments {
|
|||
export class DataTableFormat extends Component<DataTableFormatProps, DataTableFormatState> {
|
||||
static propTypes = {
|
||||
data: PropTypes.object.isRequired,
|
||||
exportTitle: PropTypes.string.isRequired,
|
||||
uiSettings: PropTypes.object.isRequired,
|
||||
fieldFormats: PropTypes.object.isRequired,
|
||||
uiActions: PropTypes.object.isRequired,
|
||||
|
@ -166,7 +161,6 @@ export class DataTableFormat extends Component<DataTableFormatProps, DataTableFo
|
|||
const fieldFormatter = fieldFormats.deserialize(formatParams);
|
||||
const filterable = isFilterable(dataColumn);
|
||||
return {
|
||||
originalColumn: () => dataColumn,
|
||||
name: dataColumn.name,
|
||||
field: dataColumn.id,
|
||||
sortable: true,
|
||||
|
@ -197,30 +191,14 @@ export class DataTableFormat extends Component<DataTableFormatProps, DataTableFo
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={true} />
|
||||
<EuiFlexItem grow={false}>
|
||||
<DataDownloadOptions
|
||||
title={this.props.exportTitle}
|
||||
csvSeparator={this.csvSeparator}
|
||||
quoteValues={this.quoteValues}
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
fieldFormats={this.props.fieldFormats}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<EuiInMemoryTable
|
||||
className="insDataTableFormat__table"
|
||||
data-test-subj="inspectorTable"
|
||||
columns={columns}
|
||||
items={rows}
|
||||
sorting={true}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</>
|
||||
<EuiInMemoryTable
|
||||
className="insDataTableFormat__table"
|
||||
data-test-subj="inspectorTable"
|
||||
columns={columns}
|
||||
items={rows}
|
||||
sorting={true}
|
||||
pagination={pagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenuPanel,
|
||||
EuiContextMenuItem,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
} from '@elastic/eui';
|
||||
import { Datatable } from '../../../../../expressions/public';
|
||||
|
||||
interface TableSelectorState {
|
||||
isPopoverOpen: boolean;
|
||||
}
|
||||
|
||||
interface TableSelectorProps {
|
||||
tables: Datatable[];
|
||||
selectedTable: Datatable;
|
||||
onTableChanged: Function;
|
||||
}
|
||||
|
||||
export class TableSelector extends Component<TableSelectorProps, TableSelectorState> {
|
||||
static propTypes = {
|
||||
tables: PropTypes.array.isRequired,
|
||||
selectedTable: PropTypes.object.isRequired,
|
||||
onTableChanged: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
isPopoverOpen: false,
|
||||
};
|
||||
|
||||
togglePopover = () => {
|
||||
this.setState((prevState: TableSelectorState) => ({
|
||||
isPopoverOpen: !prevState.isPopoverOpen,
|
||||
}));
|
||||
};
|
||||
|
||||
closePopover = () => {
|
||||
this.setState({
|
||||
isPopoverOpen: false,
|
||||
});
|
||||
};
|
||||
|
||||
renderTableDropdownItem = (table: Datatable, index: number) => {
|
||||
return (
|
||||
<EuiContextMenuItem
|
||||
key={index}
|
||||
icon={table === this.props.selectedTable ? 'check' : 'empty'}
|
||||
onClick={() => {
|
||||
this.props.onTableChanged(table);
|
||||
this.closePopover();
|
||||
}}
|
||||
data-test-subj={`inspectorTableChooser${index}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="data.inspector.table.tableLabel"
|
||||
defaultMessage="Table {index}"
|
||||
values={{ index: index + 1 }}
|
||||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const currentIndex = this.props.tables.findIndex((table) => table === this.props.selectedTable);
|
||||
return (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="data.inspector.table.tableSelectorLabel"
|
||||
defaultMessage="Selected:"
|
||||
/>
|
||||
</strong>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true}>
|
||||
<EuiPopover
|
||||
id="inspectorTableChooser"
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
size="s"
|
||||
onClick={this.togglePopover}
|
||||
data-test-subj="inspectorTableChooser"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="data.inspector.table.tableLabel"
|
||||
defaultMessage="Table {index}"
|
||||
values={{ index: currentIndex + 1 }}
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={this.state.isPopoverOpen}
|
||||
closePopover={this.closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
repositionOnScroll
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
items={this.props.tables.map(this.renderTableDropdownItem)}
|
||||
data-test-subj="inspectorTableChooserMenuPanel"
|
||||
/>
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,8 +11,11 @@ import { getTableViewDescription } from '../index';
|
|||
import { mountWithIntl } from '@kbn/test/jest';
|
||||
import { TablesAdapter } from '../../../../../expressions/common';
|
||||
|
||||
jest.mock('./export_csv', () => ({
|
||||
exportAsCsv: jest.fn(),
|
||||
jest.mock('../../../../../share/public', () => ({
|
||||
downloadMultipleAs: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../common', () => ({
|
||||
datatableToCSV: jest.fn().mockReturnValue('csv'),
|
||||
}));
|
||||
|
||||
describe('Inspector Data View', () => {
|
||||
|
@ -21,8 +24,10 @@ describe('Inspector Data View', () => {
|
|||
beforeEach(() => {
|
||||
DataView = getTableViewDescription(() => ({
|
||||
uiActions: {} as any,
|
||||
uiSettings: {} as any,
|
||||
fieldFormats: {} as any,
|
||||
uiSettings: { get: (key: string, value: string) => value } as any,
|
||||
fieldFormats: {
|
||||
deserialize: jest.fn().mockReturnValue({ convert: (v: string) => v }),
|
||||
} as any,
|
||||
isFilterable: jest.fn(),
|
||||
}));
|
||||
});
|
||||
|
@ -59,5 +64,46 @@ describe('Inspector Data View', () => {
|
|||
component.update();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render single table without selector', async () => {
|
||||
const component = mountWithIntl(
|
||||
// eslint-disable-next-line react/jsx-pascal-case
|
||||
<DataView.component title="Test Data" adapters={adapters} />
|
||||
);
|
||||
adapters.tables.logDatatable('table1', {
|
||||
columns: [{ id: '1', name: 'column1', meta: { type: 'number' } }],
|
||||
rows: [{ '1': 123 }],
|
||||
type: 'datatable',
|
||||
});
|
||||
// After the loader has resolved we'll still need one update, to "flush" the state changes
|
||||
component.update();
|
||||
expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(0);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should support multiple datatables', async () => {
|
||||
const multitableAdapter = { tables: new TablesAdapter() };
|
||||
|
||||
const component = mountWithIntl(
|
||||
// eslint-disable-next-line react/jsx-pascal-case
|
||||
<DataView.component title="Test Data" adapters={multitableAdapter} />
|
||||
);
|
||||
multitableAdapter.tables.logDatatable('table1', {
|
||||
columns: [{ id: '1', name: 'column1', meta: { type: 'number' } }],
|
||||
rows: [{ '1': 123 }],
|
||||
type: 'datatable',
|
||||
});
|
||||
multitableAdapter.tables.logDatatable('table2', {
|
||||
columns: [{ id: '1', name: 'column1', meta: { type: 'number' } }],
|
||||
rows: [{ '1': 456 }],
|
||||
type: 'datatable',
|
||||
});
|
||||
// After the loader has resolved we'll still need one update, to "flush" the state changes
|
||||
component.update();
|
||||
expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(1);
|
||||
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiEmptyPrompt } from '@elastic/eui';
|
||||
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
|
||||
import { DataTableFormat } from './data_table';
|
||||
import { IUiSettingsClient } from '../../../../../../core/public';
|
||||
|
@ -17,6 +17,8 @@ import { InspectorViewProps, Adapters } from '../../../../../inspector/public';
|
|||
import { UiActionsStart } from '../../../../../ui_actions/public';
|
||||
import { FieldFormatsStart } from '../../../field_formats';
|
||||
import { TablesAdapter, Datatable, DatatableColumn } from '../../../../../expressions/public';
|
||||
import { TableSelector } from './data_table_selector';
|
||||
import { DataDownloadOptions } from './download_options';
|
||||
|
||||
interface DataViewComponentState {
|
||||
datatable: Datatable;
|
||||
|
@ -72,6 +74,12 @@ class DataViewComponent extends Component<DataViewComponentProps, DataViewCompon
|
|||
}
|
||||
};
|
||||
|
||||
selectTable = (datatable: Datatable) => {
|
||||
if (datatable !== this.state.datatable) {
|
||||
this.setState({ datatable });
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.adapters.tables!.on('change', this.onUpdateData);
|
||||
}
|
||||
|
@ -110,15 +118,54 @@ class DataViewComponent extends Component<DataViewComponentProps, DataViewCompon
|
|||
return DataViewComponent.renderNoData();
|
||||
}
|
||||
|
||||
const datatables = Object.values(this.state.adapters.tables.tables) as Datatable[];
|
||||
|
||||
return (
|
||||
<DataTableFormat
|
||||
data={this.state.datatable}
|
||||
exportTitle={this.props.options?.fileName || this.props.title}
|
||||
uiSettings={this.props.uiSettings}
|
||||
fieldFormats={this.props.fieldFormats}
|
||||
uiActions={this.props.uiActions}
|
||||
isFilterable={this.props.isFilterable}
|
||||
/>
|
||||
<>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={true}>
|
||||
{datatables.length > 1 ? (
|
||||
<>
|
||||
<EuiText size="xs">
|
||||
<p role="status" aria-live="polite" aria-atomic="true">
|
||||
<FormattedMessage
|
||||
data-test-subj="inspectorDataViewSelectorLabel"
|
||||
id="data.inspector.table.tablesDescription"
|
||||
defaultMessage="There are {tablesCount, plural, one {# table} other {# tables} } in total"
|
||||
values={{
|
||||
tablesCount: datatables.length,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer size="xs" />
|
||||
<TableSelector
|
||||
tables={datatables}
|
||||
selectedTable={this.state.datatable}
|
||||
onTableChanged={this.selectTable}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<DataDownloadOptions
|
||||
title={this.props.options?.fileName || this.props.title}
|
||||
uiSettings={this.props.uiSettings}
|
||||
datatables={datatables}
|
||||
fieldFormats={this.props.fieldFormats}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
|
||||
<DataTableFormat
|
||||
data={this.state.datatable}
|
||||
uiSettings={this.props.uiSettings}
|
||||
fieldFormats={this.props.fieldFormats}
|
||||
uiActions={this.props.uiActions}
|
||||
isFilterable={this.props.isFilterable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ import { FormattedMessage } from '@kbn/i18n/react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import { DataViewColumn, DataViewRow } from '../types';
|
||||
import { exportAsCsv } from './export_csv';
|
||||
import { CSV_MIME_TYPE, datatableToCSV } from '../../../../common';
|
||||
import { Datatable } from '../../../../../expressions';
|
||||
import { downloadMultipleAs } from '../../../../../share/public';
|
||||
import { FieldFormatsStart } from '../../../field_formats';
|
||||
import { IUiSettingsClient } from '../../../../../../core/public';
|
||||
|
||||
interface DataDownloadOptionsState {
|
||||
isPopoverOpen: boolean;
|
||||
|
@ -22,10 +24,8 @@ interface DataDownloadOptionsState {
|
|||
|
||||
interface DataDownloadOptionsProps {
|
||||
title: string;
|
||||
columns: DataViewColumn[];
|
||||
rows: DataViewRow[];
|
||||
csvSeparator: string;
|
||||
quoteValues: boolean;
|
||||
datatables: Datatable[];
|
||||
uiSettings: IUiSettingsClient;
|
||||
isFormatted?: boolean;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
}
|
||||
|
@ -33,10 +33,8 @@ interface DataDownloadOptionsProps {
|
|||
class DataDownloadOptions extends Component<DataDownloadOptionsProps, DataDownloadOptionsState> {
|
||||
static propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
csvSeparator: PropTypes.string.isRequired,
|
||||
quoteValues: PropTypes.bool.isRequired,
|
||||
columns: PropTypes.array,
|
||||
rows: PropTypes.array,
|
||||
uiSettings: PropTypes.object.isRequired,
|
||||
datatables: PropTypes.array,
|
||||
fieldFormats: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
@ -63,15 +61,30 @@ class DataDownloadOptions extends Component<DataDownloadOptionsProps, DataDownlo
|
|||
defaultMessage: 'unsaved',
|
||||
});
|
||||
}
|
||||
exportAsCsv({
|
||||
filename: `${filename}.csv`,
|
||||
columns: this.props.columns,
|
||||
rows: this.props.rows,
|
||||
csvSeparator: this.props.csvSeparator,
|
||||
quoteValues: this.props.quoteValues,
|
||||
isFormatted,
|
||||
fieldFormats: this.props.fieldFormats,
|
||||
});
|
||||
|
||||
const content = this.props.datatables.reduce<Record<string, { content: string; type: string }>>(
|
||||
(memo, datatable, i) => {
|
||||
// skip empty datatables
|
||||
if (datatable) {
|
||||
const postFix = this.props.datatables.length > 1 ? `-${i + 1}` : '';
|
||||
|
||||
memo[`${filename}${postFix}.csv`] = {
|
||||
content: datatableToCSV(datatable, {
|
||||
csvSeparator: this.props.uiSettings.get('csv:separator', ','),
|
||||
quoteValues: this.props.uiSettings.get('csv:quoteValues', true),
|
||||
raw: !isFormatted,
|
||||
formatFactory: this.props.fieldFormats.deserialize,
|
||||
}),
|
||||
type: CSV_MIME_TYPE,
|
||||
};
|
||||
}
|
||||
return memo;
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (content) {
|
||||
downloadMultipleAs(content);
|
||||
}
|
||||
};
|
||||
|
||||
exportFormattedCsv = () => {
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { isObject } from 'lodash';
|
||||
|
||||
// @ts-ignore
|
||||
import { saveAs } from '@elastic/filesaver';
|
||||
|
||||
import { DataViewColumn, DataViewRow } from '../types';
|
||||
import { FieldFormatsStart } from '../../../field_formats/field_formats_service';
|
||||
|
||||
const LINE_FEED_CHARACTER = '\r\n';
|
||||
const nonAlphaNumRE = /[^a-zA-Z0-9]/;
|
||||
const allDoubleQuoteRE = /"/g;
|
||||
|
||||
function escape(val: string, quoteValues: boolean) {
|
||||
if (isObject(val)) {
|
||||
val = (val as any).valueOf();
|
||||
}
|
||||
|
||||
val = String(val);
|
||||
|
||||
if (quoteValues && nonAlphaNumRE.test(val)) {
|
||||
val = `"${val.replace(allDoubleQuoteRE, '""')}"`;
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
function buildCsv(
|
||||
columns: DataViewColumn[],
|
||||
rows: DataViewRow[],
|
||||
csvSeparator: string,
|
||||
quoteValues: boolean,
|
||||
isFormatted: boolean,
|
||||
fieldFormats: FieldFormatsStart
|
||||
) {
|
||||
// Build the header row by its names
|
||||
const header = columns.map((col) => escape(col.name, quoteValues));
|
||||
|
||||
const formatters = columns.map((column) => {
|
||||
return fieldFormats.deserialize(column.originalColumn().meta.params);
|
||||
});
|
||||
|
||||
// Convert the array of row objects to an array of row arrays
|
||||
const csvRows = rows.map((row) => {
|
||||
return columns.map((column, i) => {
|
||||
return escape(
|
||||
isFormatted ? formatters[i].convert(row[column.field]) : row[column.field],
|
||||
quoteValues
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
[header, ...csvRows].map((row) => row.join(csvSeparator)).join(LINE_FEED_CHARACTER) +
|
||||
LINE_FEED_CHARACTER
|
||||
); // Add \r\n after last line
|
||||
}
|
||||
|
||||
export function exportAsCsv({
|
||||
filename,
|
||||
columns,
|
||||
rows,
|
||||
isFormatted,
|
||||
csvSeparator,
|
||||
quoteValues,
|
||||
fieldFormats,
|
||||
}: any) {
|
||||
const type = 'text/plain;charset=utf-8';
|
||||
|
||||
const csv = new Blob(
|
||||
[buildCsv(columns, rows, csvSeparator, quoteValues, isFormatted, fieldFormats)],
|
||||
{
|
||||
type,
|
||||
}
|
||||
);
|
||||
saveAs(csv, filename);
|
||||
}
|
|
@ -6,15 +6,16 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Datatable, DatatableColumn, DatatableRow } from '../../../../expressions/common';
|
||||
|
||||
type DataViewColumnRender = (value: string, _item: DatatableRow) => string;
|
||||
type DataViewColumnRender = (value: string, _item: DatatableRow) => React.ReactNode | string;
|
||||
|
||||
export interface DataViewColumn {
|
||||
originalColumn: () => DatatableColumn;
|
||||
name: string;
|
||||
field: string;
|
||||
sortable: (item: DatatableRow) => string | number;
|
||||
sortable: boolean | ((item: DatatableRow) => string | number);
|
||||
render: DataViewColumnRender;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue