[Inspector][Lens] Add multitable support to Inspector (#94077)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2021-03-10 18:43:16 +01:00 committed by GitHub
parent 81da4f79e1
commit 08f3fe7ef5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 3095 additions and 150 deletions

View file

@ -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}
/>
);
}
}

View file

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

View file

@ -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();
});
});
});

View file

@ -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}
/>
</>
);
}
}

View file

@ -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 = () => {

View file

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

View file

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