[ML] Data Frame UI aggs enhancements (#35595) (#35790)

- Adds ability to change the name of the group-by or aggregation configuration. Form validation matches the limitations of ES aggregation names.
- Adds ability to change aggregation type and/or field where applicable for create aggregations
Previously, internally we used separate information to store an ID and a display name for each configuration. Because ES aggregation names support all characters except []>, this is no longer necessary, so the internal attributes formRowLabel and optionsDataId are now merged to aggName.
- Fixes an issue with EuiInMemoryTable where changing columns in the source index and pivot preview table would crash Kibana.
- Improved handling of adding/updating/deleting items from group-by and agg configs.
This commit is contained in:
Walter Rafelsberger 2019-04-30 14:05:41 +02:00 committed by GitHub
parent d7be854275
commit 492198b5d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1123 additions and 495 deletions

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isAggName } from './aggregations';
describe('Data Frame: Aggregations', () => {
test('isAggName()', () => {
expect(isAggName('avg(responsetime)')).toEqual(true);
expect(isAggName('avg_responsetime')).toEqual(true);
expect(isAggName('avg[responsetime]')).toEqual(false);
expect(isAggName('avg<responsetime>')).toEqual(false);
expect(isAggName('avg responsetime')).toEqual(true);
expect(isAggName(' ')).toEqual(false);
expect(isAggName(' avg responsetime')).toEqual(false);
expect(isAggName('avg responsetime ')).toEqual(false);
expect(isAggName(' avg responsetime ')).toEqual(false);
expect(isAggName('date_histogram(@timestamp')).toEqual(true);
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type AggName = string;
export type FieldName = string;
export function isAggName(arg: any): arg is AggName {
// allow all characters except `[]>` and must not start or end with a space.
return /^[^\s^\[\]>][^\[\]>]+[^\s^\[\]>]$/.test(arg);
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './aggregations';
export * from './dropdown';
export * from './index_pattern_context';
export * from './pivot_aggs';

View file

@ -6,6 +6,8 @@
import { Dictionary } from '../../../common/types/common';
import { AggName, FieldName } from './aggregations';
export enum PIVOT_SUPPORTED_AGGS {
AVG = 'avg',
CARDINALITY = 'cardinality',
@ -41,12 +43,10 @@ type PivotAgg = {
export type PivotAggDict = { [key in AggName]: PivotAgg };
// The internal representation of an aggregation definition.
type AggName = string;
type FieldName = string;
export interface PivotAggsConfig {
agg: PivotAggSupportedAggs;
field: FieldName;
formRowLabel: AggName;
aggName: AggName;
}
export type PivotAggsConfigDict = Dictionary<PivotAggsConfig>;

View file

@ -6,17 +6,26 @@
import { Dictionary } from '../../../common/types/common';
import { AggName, FieldName } from './aggregations';
export enum PIVOT_SUPPORTED_GROUP_BY_AGGS {
DATE_HISTOGRAM = 'date_histogram',
HISTOGRAM = 'histogram',
TERMS = 'terms',
}
type FieldName = string;
export type PivotSupportedGroupByAggs =
| PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM
| PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM
| PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS;
export type PivotSupportedGroupByAggsWithInterval =
| PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM
| PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM;
interface GroupByConfigBase {
field: FieldName;
formRowLabel: string;
aggName: AggName;
}
// Don't allow an interval of '0', but allow a float interval of '0.1' with a leading zero.
@ -49,8 +58,8 @@ interface GroupByTerms extends GroupByConfigBase {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS;
}
type GroupByConfigWithInterval = GroupByDateHistogram | GroupByHistogram;
export type PivotGroupByConfig = GroupByConfigWithInterval | GroupByTerms;
export type GroupByConfigWithInterval = GroupByDateHistogram | GroupByHistogram;
export type PivotGroupByConfig = GroupByDateHistogram | GroupByHistogram | GroupByTerms;
export type PivotGroupByConfigDict = Dictionary<PivotGroupByConfig>;
export function groupByConfigHasInterval(arg: any): arg is GroupByConfigWithInterval {

View file

@ -31,11 +31,11 @@ describe('Data Frame: Common', () => {
{
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
},
];
const aggs: PivotAggsConfig[] = [
{ agg: PIVOT_SUPPORTED_AGGS.AVG, field: 'the-agg-field', formRowLabel: 'the-agg-label' },
{ agg: PIVOT_SUPPORTED_AGGS.AVG, field: 'the-agg-field', aggName: 'the-agg-label' },
];
const request = getDataFramePreviewRequest('the-index-pattern-title', query, groupBy, aggs);
@ -55,14 +55,15 @@ describe('Data Frame: Common', () => {
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-agg-label',
};
const aggs: PivotAggsConfig[] = [
{ agg: PIVOT_SUPPORTED_AGGS.AVG, field: 'the-agg-field', formRowLabel: 'the-agg-label' },
];
const pivotState: DefinePivotExposedState = {
aggList: ['the-agg-name'],
aggs,
aggList: { 'the-agg-name': agg },
groupByList: { 'the-group-by-name': groupBy },
search: 'the-query',
valid: true,

View file

@ -85,7 +85,7 @@ export function getDataFramePreviewRequest(
field: g.field,
},
};
request.pivot.group_by[g.formRowLabel] = termsAgg;
request.pivot.group_by[g.aggName] = termsAgg;
} else if (g.agg === PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM) {
const histogramAgg: HistogramAgg = {
histogram: {
@ -93,7 +93,7 @@ export function getDataFramePreviewRequest(
interval: g.interval,
},
};
request.pivot.group_by[g.formRowLabel] = histogramAgg;
request.pivot.group_by[g.aggName] = histogramAgg;
} else if (g.agg === PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM) {
const dateHistogramAgg: DateHistogramAgg = {
date_histogram: {
@ -116,12 +116,12 @@ export function getDataFramePreviewRequest(
dateHistogramAgg.date_histogram.format = format;
}
}
request.pivot.group_by[g.formRowLabel] = dateHistogramAgg;
request.pivot.group_by[g.aggName] = dateHistogramAgg;
}
});
aggs.forEach(agg => {
request.pivot.aggregations[agg.formRowLabel] = {
request.pivot.aggregations[agg.aggName] = {
[agg.agg]: {
field: agg.field,
},
@ -141,7 +141,7 @@ export function getDataFrameRequest(
indexPatternTitle,
getPivotQuery(pivotState.search),
dictionaryToArray(pivotState.groupByList),
pivotState.aggs
dictionaryToArray(pivotState.aggList)
),
dest: {
index: jobDetailsState.targetIndex,

View file

@ -0,0 +1,62 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: <AggLabelForm /> Date histogram aggregation 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
>
<EuiFlexItem>
the-group-by-label
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit aggregation"
color="primary"
iconSize="m"
iconType="pencil"
onClick={[Function]}
size="s"
type="button"
/>
}
closePopover={[Function]}
hasArrow={true}
id="mlFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "cardinality",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButtonIcon
aria-label="Delete item"
color="primary"
iconSize="m"
iconType="cross"
onClick={[Function]}
size="s"
type="button"
/>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -1,71 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: <AggListForm /> Minimal initialization 1`] = `
<EuiListGroup
bordered={false}
flush={true}
maxWidth={true}
showToolTips={false}
wrapText={false}
>
<EuiListGroupItem
isActive={false}
isDisabled={false}
key="the-agg"
label={
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Custom name"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="the-form-row-label"
fullWidth={false}
isLoading={false}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Aggregation"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="avg"
fullWidth={false}
isLoading={false}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={false}
label="Field"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="the-field"
fullWidth={false}
isLoading={false}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
}
showToolTip={false}
size="m"
<Fragment>
<EuiPanel
grow={true}
hasShadow={false}
paddingSize="s"
>
<EuiFlexGroup>
<EuiFlexItem>
<AggLabelForm
deleteHandler={[Function]}
item={
Object {
"agg": "avg",
"aggName": "the-form-row-label",
"field": "the-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer
size="s"
/>
</EuiListGroup>
</Fragment>
`;

View file

@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: Aggregation <PopoverForm /> Minimal initialization 1`] = `
<EuiForm
style={
Object {
"width": "300px",
}
}
>
<EuiFormRow
describedByIds={Array []}
error={false}
fullWidth={false}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Aggregation name"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="the-agg-name"
fullWidth={false}
isInvalid={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={true}
labelType="label"
>
<EuiButton
color="primary"
fill={false}
iconSide="left"
isDisabled={false}
onClick={[Function]}
size="m"
type="button"
>
Apply
</EuiButton>
</EuiFormRow>
</EuiForm>
`;

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { AggName, PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from '../../common';
import { AggLabelForm } from './agg_label_form';
describe('Data Frame: <AggLabelForm />', () => {
test('Date histogram aggregation', () => {
const item: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.CARDINALITY,
field: 'the-group-by-field',
aggName: 'the-group-by-label',
};
const props = {
item,
otherAggNames: [],
options: {},
deleteHandler(l: AggName) {},
onChange() {},
};
const wrapper = shallow(<AggLabelForm {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui';
import { AggName, PivotAggsConfig, PivotAggsConfigDict } from '../../common';
import { PopoverForm } from './popover_form';
interface Props {
item: PivotAggsConfig;
otherAggNames: AggName[];
options: PivotAggsConfigDict;
deleteHandler(l: AggName): void;
onChange(item: PivotAggsConfig): void;
}
export const AggLabelForm: React.SFC<Props> = ({
deleteHandler,
item,
otherAggNames,
onChange,
options,
}) => {
const [isPopoverVisible, setPopoverVisibility] = useState(false);
function update(updateItem: PivotAggsConfig) {
onChange({ ...updateItem });
setPopoverVisibility(false);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>{item.aggName}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
id="mlFormPopover"
ownFocus
button={
<EuiButtonIcon
aria-label={i18n.translate('xpack.ml.dataframe.aggLabelForm.editAggAriaLabel', {
defaultMessage: 'Edit aggregation',
})}
size="s"
iconType="pencil"
onClick={() => setPopoverVisibility(!isPopoverVisible)}
/>
}
isOpen={isPopoverVisible}
closePopover={() => setPopoverVisibility(false)}
>
<PopoverForm
defaultData={item}
onChange={update}
otherAggNames={otherAggNames}
options={options}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
aria-label={i18n.translate('xpack.ml.dataframe.aggLabelForm.deleteItemAriaLabel', {
defaultMessage: 'Delete item',
})}
size="s"
iconType="cross"
onClick={() => deleteHandler(item.aggName)}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -7,21 +7,22 @@
import { shallow } from 'enzyme';
import React from 'react';
import { PIVOT_SUPPORTED_AGGS } from '../../common';
import { PivotAggsConfig, PIVOT_SUPPORTED_AGGS } from '../../common';
import { AggListForm, ListProps } from './list_form';
describe('Data Frame: <AggListForm />', () => {
test('Minimal initialization', () => {
const item: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-field',
aggName: 'the-form-row-label',
};
const props: ListProps = {
list: ['the-agg'],
optionsData: {
'the-agg': {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-field',
formRowLabel: 'the-form-row-label',
},
},
list: { 'the-agg': item },
options: {},
deleteHandler() {},
onChange() {},
};
const wrapper = shallow(<AggListForm {...props} />);

View file

@ -4,74 +4,46 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiListGroup,
EuiListGroupItem,
} from '@elastic/eui';
import { AggName, PivotAggsConfig, PivotAggsConfigDict } from '../../common';
import { PivotAggsConfigDict } from '../../common';
import { AggLabelForm } from './agg_label_form';
export interface ListProps {
list: string[];
optionsData: PivotAggsConfigDict;
deleteHandler?(l: string): void;
list: PivotAggsConfigDict;
options: PivotAggsConfigDict;
deleteHandler(l: string): void;
onChange(previousAggName: AggName, item: PivotAggsConfig): void;
}
export const AggListForm: React.SFC<ListProps> = ({ deleteHandler, list, optionsData }) => (
<EuiListGroup flush={true}>
{list.map((optionsDataId: string) => (
<EuiListGroupItem
key={optionsDataId}
label={
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.customNameLabel', {
defaultMessage: 'Custom name',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].formRowLabel} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.aggregationLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].agg} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.fieldLabel', {
defaultMessage: 'Field',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].field} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
(deleteHandler && {
onClick: () => deleteHandler(optionsDataId),
iconType: 'cross',
iconSize: 's',
'aria-label': optionsDataId,
alwaysShow: false,
}) ||
undefined
}
/>
))}
</EuiListGroup>
);
export const AggListForm: React.SFC<ListProps> = ({ deleteHandler, list, onChange, options }) => {
const listKeys = Object.keys(list);
return (
<Fragment>
{listKeys.map((aggName: AggName) => {
const otherAggNames = listKeys.filter(k => k !== aggName);
return (
<Fragment key={aggName}>
<EuiPanel paddingSize="s">
<EuiFlexGroup>
<EuiFlexItem>
<AggLabelForm
deleteHandler={deleteHandler}
item={list[aggName]}
onChange={item => onChange(aggName, item)}
otherAggNames={otherAggNames}
options={options}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
{listKeys.length > 0 && <EuiSpacer size="s" />}
</Fragment>
);
})}
</Fragment>
);
};

View file

@ -8,26 +8,22 @@ import React from 'react';
import { EuiForm, EuiFormRow } from '@elastic/eui';
import { Dictionary } from '../../../../common/types/common';
interface OptionsDataElement {
agg: string;
field: string;
formRowLabel: string;
}
import { AggName, PivotAggsConfigDict } from '../../common';
interface ListProps {
list: string[];
optionsData: Dictionary<OptionsDataElement>;
list: PivotAggsConfigDict;
deleteHandler?(l: string): void;
}
export const AggListSummary: React.SFC<ListProps> = ({ list, optionsData }) => (
<EuiForm>
{list.map((l: string) => (
<EuiFormRow key={l} label={optionsData[l].formRowLabel}>
<span>{l}</span>
</EuiFormRow>
))}
</EuiForm>
);
export const AggListSummary: React.SFC<ListProps> = ({ list }) => {
const listKeys = Object.keys(list);
return (
<EuiForm>
{listKeys.map((l: AggName) => (
<EuiFormRow key={l} label={list[l].aggName}>
<span>{l}</span>
</EuiFormRow>
))}
</EuiForm>
);
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { AggName, PIVOT_SUPPORTED_AGGS, PivotAggsConfig } from '../../common';
import { PopoverForm } from './popover_form';
describe('Data Frame: Aggregation <PopoverForm />', () => {
test('Minimal initialization', () => {
const defaultData: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.CARDINALITY,
aggName: 'the-agg-name',
field: 'the-field',
};
const otherAggNames: AggName[] = [];
const onChange = (item: PivotAggsConfig) => {};
const wrapper = shallow(
<PopoverForm
defaultData={defaultData}
otherAggNames={otherAggNames}
options={{}}
onChange={onChange}
/>
);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -0,0 +1,128 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui';
import { dictionaryToArray } from '../../../../common/types/common';
import {
AggName,
isAggName,
PivotAggsConfig,
PivotAggsConfigDict,
PIVOT_SUPPORTED_AGGS,
} from '../../common';
interface SelectOption {
text: string;
}
interface Props {
defaultData: PivotAggsConfig;
otherAggNames: AggName[];
options: PivotAggsConfigDict;
onChange(d: PivotAggsConfig): void;
}
export const PopoverForm: React.SFC<Props> = ({
defaultData,
otherAggNames,
onChange,
options,
}) => {
const [aggName, setAggName] = useState(defaultData.aggName);
const [agg, setAgg] = useState(defaultData.agg);
const [field, setField] = useState(defaultData.field);
const optionsArr = dictionaryToArray(options);
const availableFields: SelectOption[] = optionsArr
.filter(o => o.agg === defaultData.agg)
.map(o => {
return { text: o.field };
});
const availableAggs: SelectOption[] = optionsArr
.filter(o => o.field === defaultData.field)
.map(o => {
return { text: o.agg };
});
let aggNameError = '';
let validAggName = isAggName(aggName);
if (!validAggName) {
aggNameError = i18n.translate('xpack.ml.dataframe.agg.popoverForm.aggNameInvalidCharError', {
defaultMessage:
'Invalid name. The characters "[", "]", and ">" are not allowed and the name must not start or end with a space character.',
});
}
if (validAggName) {
validAggName = !otherAggNames.includes(aggName);
aggNameError = i18n.translate('xpack.ml.dataframe.agg.popoverForm.aggNameAlreadyUsedError', {
defaultMessage: 'Another aggregation already uses that name.',
});
}
const formValid = validAggName;
return (
<EuiForm style={{ width: '300px' }}>
<EuiFormRow
error={!validAggName && [aggNameError]}
isInvalid={!validAggName}
label={i18n.translate('xpack.ml.dataframe.agg.popoverForm.nameLabel', {
defaultMessage: 'Aggregation name',
})}
>
<EuiFieldText
defaultValue={aggName}
isInvalid={!validAggName}
onChange={e => setAggName(e.target.value)}
/>
</EuiFormRow>
{availableAggs.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.agg.popoverForm.aggLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiSelect
options={availableAggs}
value={agg}
onChange={e => setAgg(e.target.value as PIVOT_SUPPORTED_AGGS)}
/>
</EuiFormRow>
)}
{availableFields.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.agg.popoverForm.fieldLabel', {
defaultMessage: 'Field',
})}
>
<EuiSelect
options={availableFields}
value={field}
onChange={e => setField(e.target.value)}
/>
</EuiFormRow>
)}
<EuiFormRow hasEmptyLabelSpace>
<EuiButton
isDisabled={!formValid}
onClick={() => onChange({ ...defaultData, aggName, agg, field })}
>
{i18n.translate('xpack.ml.dataframe.agg.popoverForm.submitButtonLabel', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFormRow>
</EuiForm>
);
};

View file

@ -12,25 +12,20 @@ exports[`Data Frame: <DefinePivotSummary /> Minimal initialization 1`] = `
>
<DefinePivotSummary
aggList={
Array [
"the-agg-name",
]
}
aggs={
Array [
Object {
Object {
"the-agg-name": Object {
"agg": "avg",
"aggName": "the-agg-label",
"field": "the-agg-field",
"formRowLabel": "the-agg-label",
},
]
}
}
groupByList={
Object {
"the-group-by-name": Object {
"agg": "terms",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"formRowLabel": "the-group-by-label",
},
}
}

View file

@ -12,20 +12,20 @@ exports[`Data Frame: <PivotPreview /> Minimal initialization 1`] = `
>
<Component
aggs={
Array [
Object {
Object {
"the-agg-name": Object {
"agg": "avg",
"aggName": "the-agg-label",
"field": "the-agg-field",
"formRowLabel": "the-agg-label",
},
]
}
}
groupBy={
Object {
"the-group-by-name": Object {
"agg": "terms",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"formRowLabel": "the-group-by-label",
},
}
}

View file

@ -31,14 +31,14 @@ describe('Data Frame: Define Pivot Common', () => {
},
],
aggOptionsData: {
'avg(the-field)': { agg: 'avg', field: 'the-field', formRowLabel: 'avg_the-field' },
'max(the-field)': { agg: 'max', field: 'the-field', formRowLabel: 'max_the-field' },
'min(the-field)': { agg: 'min', field: 'the-field', formRowLabel: 'min_the-field' },
'sum(the-field)': { agg: 'sum', field: 'the-field', formRowLabel: 'sum_the-field' },
'avg(the-field)': { agg: 'avg', field: 'the-field', aggName: 'avg(the-field)' },
'max(the-field)': { agg: 'max', field: 'the-field', aggName: 'max(the-field)' },
'min(the-field)': { agg: 'min', field: 'the-field', aggName: 'min(the-field)' },
'sum(the-field)': { agg: 'sum', field: 'the-field', aggName: 'sum(the-field)' },
'value_count(the-field)': {
agg: 'value_count',
field: 'the-field',
formRowLabel: 'value_count_the-field',
aggName: 'value_count(the-field)',
},
},
groupByOptions: [{ label: 'histogram(the-field)' }],
@ -46,7 +46,7 @@ describe('Data Frame: Define Pivot Common', () => {
'histogram(the-field)': {
agg: 'histogram',
field: 'the-field',
formRowLabel: 'histogram_the-field',
aggName: 'histogram(the-field)',
interval: '10',
},
},

View file

@ -41,35 +41,32 @@ export function getPivotDropdownOptions(indexPattern: StaticIndexPattern) {
fields.forEach(field => {
// group by
if (field.type === FIELD_TYPE.STRING) {
const label = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS}(${field.name})`;
const groupByOption: DropDownLabel = { label };
const aggName = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS}(${field.name})`;
const groupByOption: DropDownLabel = { label: aggName };
groupByOptions.push(groupByOption);
const formRowLabel = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS}_${field.name}`;
groupByOptionsData[label] = {
groupByOptionsData[aggName] = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: field.name,
formRowLabel,
aggName,
};
} else if (field.type === FIELD_TYPE.NUMBER) {
const label = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM}(${field.name})`;
const groupByOption: DropDownLabel = { label };
const aggName = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM}(${field.name})`;
const groupByOption: DropDownLabel = { label: aggName };
groupByOptions.push(groupByOption);
const formRowLabel = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM}_${field.name}`;
groupByOptionsData[label] = {
groupByOptionsData[aggName] = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM,
field: field.name,
formRowLabel,
aggName,
interval: '10',
};
} else if (field.type === FIELD_TYPE.DATE) {
const label = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM}(${field.name})`;
const groupByOption: DropDownLabel = { label };
const aggName = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM}(${field.name})`;
const groupByOption: DropDownLabel = { label: aggName };
groupByOptions.push(groupByOption);
const formRowLabel = `${PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM}_${field.name}`;
groupByOptionsData[label] = {
groupByOptionsData[aggName] = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM,
field: field.name,
formRowLabel,
aggName,
interval: '1m',
};
}
@ -82,10 +79,9 @@ export function getPivotDropdownOptions(indexPattern: StaticIndexPattern) {
(field.type === FIELD_TYPE.STRING || field.type === FIELD_TYPE.IP)) ||
(agg !== PIVOT_SUPPORTED_AGGS.CARDINALITY && field.type === FIELD_TYPE.NUMBER)
) {
const label = `${agg}(${field.name})`;
aggOption.options.push({ label });
const formRowLabel = `${agg}_${field.name}`;
aggOptionsData[label] = { agg, field: field.name, formRowLabel };
const aggName = `${agg}(${field.name})`;
aggOption.options.push({ label: aggName });
aggOptionsData[aggName] = { agg, field: field.name, aggName };
}
});
aggOptions.push(aggOption);

View file

@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { uniq } from 'lodash';
import React, { ChangeEvent, Fragment, SFC, useContext, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, Fragment, SFC, useContext, useEffect, useState } from 'react';
import { toastNotifications } from 'ui/notify';
import {
EuiFieldSearch,
@ -28,12 +28,13 @@ import { SourceIndexPreview } from '../../components/source_index_preview';
import { PivotPreview } from './pivot_preview';
import {
AggName,
DropDownLabel,
getPivotQuery,
groupByConfigHasInterval,
IndexPatternContext,
Label,
PivotAggsConfig,
PivotAggsConfigDict,
PivotGroupByConfig,
PivotGroupByConfigDict,
} from '../../common';
@ -41,8 +42,7 @@ import {
import { getPivotDropdownOptions } from './common';
export interface DefinePivotExposedState {
aggList: Label[];
aggs: PivotAggsConfig[];
aggList: PivotAggsConfigDict;
groupByList: PivotGroupByConfigDict;
search: string;
valid: boolean;
@ -53,8 +53,7 @@ const emptySearch = '';
export function getDefaultPivotState(): DefinePivotExposedState {
return {
aggList: [] as Label[],
aggs: [] as PivotAggsConfig[],
aggList: {} as PivotAggsConfigDict,
groupByList: {} as PivotGroupByConfigDict,
search: defaultSearch,
valid: false,
@ -98,52 +97,75 @@ export const DefinePivotForm: SFC<Props> = React.memo(({ overrides = {}, onChang
aggOptionsData,
} = getPivotDropdownOptions(indexPattern);
const addGroupByInterval = (label: Label, item: PivotGroupByConfig) => {
groupByList[label] = item;
setGroupByList({ ...groupByList });
};
const addGroupBy = (d: DropDownLabel[]) => {
const label: Label = d[0].label;
groupByList[label] = groupByOptionsData[label];
const label: AggName = d[0].label;
if (groupByList[label] === undefined) {
groupByList[label] = groupByOptionsData[label];
setGroupByList({ ...groupByList });
} else {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.definePivot.groupByExistsErrorMessage', {
defaultMessage: `A group by configuration with the name '{label}' already exists.`,
values: { label },
})
);
}
};
const updateGroupBy = (previousAggName: AggName, item: PivotGroupByConfig) => {
delete groupByList[previousAggName];
groupByList[item.aggName] = item;
setGroupByList({ ...groupByList });
};
const deleteGroupBy = (label: Label) => {
const deleteGroupBy = (label: AggName) => {
delete groupByList[label];
setGroupByList({ ...groupByList });
};
// The list of selected aggregations
const [aggList, setAggList] = useState(defaults.aggList as Label[]);
const [aggList, setAggList] = useState(defaults.aggList);
const addAggregation = (d: DropDownLabel[]) => {
const label: Label = d[0].label;
const newList = uniq([...aggList, label]);
setAggList(newList);
const label: AggName = d[0].label;
if (aggList[label] === undefined) {
aggList[label] = aggOptionsData[label];
setAggList({ ...aggList });
} else {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.definePivot.aggExistsErrorMessage', {
defaultMessage: `An aggregation configuration with the name '{label}' already exists.`,
values: { label },
})
);
}
};
const deleteAggregation = (label: Label) => {
const newList = aggList.filter(l => l !== label);
setAggList(newList);
const updateAggregation = (previousAggName: AggName, item: PivotAggsConfig) => {
delete aggList[previousAggName];
aggList[item.aggName] = item;
setAggList({ ...aggList });
};
const pivotAggs = aggList.map(l => aggOptionsData[l]);
const deleteAggregation = (label: AggName) => {
delete aggList[label];
setAggList({ ...aggList });
};
const pivotAggsArr = dictionaryToArray(aggList);
const pivotGroupByArr = dictionaryToArray(groupByList);
const pivotQuery = getPivotQuery(search);
const valid = pivotGroupByArr.length > 0 && aggList.length > 0;
const valid = pivotGroupByArr.length > 0 && pivotAggsArr.length > 0;
useEffect(
() => {
onChange({ aggList, aggs: pivotAggs, groupByList, search, valid });
onChange({ aggList, groupByList, search, valid });
},
[
aggList,
pivotAggs.map(d => `${d.agg} ${d.field} ${d.formRowLabel}`).join(' '),
pivotAggsArr.map(d => `${d.agg} ${d.field} ${d.aggName}`).join(' '),
pivotGroupByArr
.map(
d =>
`${d.agg} ${d.field} ${groupByConfigHasInterval(d) ? d.interval : ''} ${d.formRowLabel}`
d => `${d.agg} ${d.field} ${groupByConfigHasInterval(d) ? d.interval : ''} ${d.aggName}`
)
.join(' '),
search,
@ -179,7 +201,8 @@ export const DefinePivotForm: SFC<Props> = React.memo(({ overrides = {}, onChang
<Fragment>
<GroupByListForm
list={groupByList}
onChange={addGroupByInterval}
options={groupByOptionsData}
onChange={updateGroupBy}
deleteHandler={deleteGroupBy}
/>
<DropDown
@ -203,7 +226,8 @@ export const DefinePivotForm: SFC<Props> = React.memo(({ overrides = {}, onChang
<Fragment>
<AggListForm
list={aggList}
optionsData={aggOptionsData}
options={aggOptionsData}
onChange={updateAggregation}
deleteHandler={deleteAggregation}
/>
<DropDown
@ -232,7 +256,7 @@ export const DefinePivotForm: SFC<Props> = React.memo(({ overrides = {}, onChang
<EuiFlexItem>
<SourceIndexPreview cellClick={addToSearch} query={pivotQuery} />
<EuiSpacer size="l" />
<PivotPreview aggs={pivotAggs} groupBy={groupByList} query={pivotQuery} />
<PivotPreview aggs={aggList} groupBy={groupByList} query={pivotQuery} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -34,14 +34,15 @@ describe('Data Frame: <DefinePivotSummary />', () => {
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-agg-label',
};
const aggs: PivotAggsConfig[] = [
{ agg: PIVOT_SUPPORTED_AGGS.AVG, field: 'the-agg-field', formRowLabel: 'the-agg-label' },
];
const props: DefinePivotExposedState = {
aggList: ['the-agg-name'],
aggs,
aggList: { 'the-agg-name': agg },
groupByList: { 'the-group-by-name': groupBy },
search: 'the-query',
valid: true,

View file

@ -65,15 +65,13 @@ export const DefinePivotSummary: SFC<DefinePivotExposedState> = ({
) {
const label = `${agg}(${field.name})`;
aggOption.options.push({ label });
const formRowLabel = `${agg}_${field.name}`;
aggOptionsData[label] = { agg, field: field.name, formRowLabel };
const aggName = `${agg}_${field.name}`;
aggOptionsData[label] = { agg, field: field.name, aggName };
}
});
aggOptions.push(aggOption);
});
const pivotAggs = aggList.map(l => aggOptionsData[l]);
const pivotGroupBy = groupByList;
const pivotQuery = getPivotQuery(search);
const displaySearch = search === defaultSearch ? emptySearch : search;
@ -103,14 +101,14 @@ export const DefinePivotSummary: SFC<DefinePivotExposedState> = ({
defaultMessage: 'Aggregations',
})}
>
<AggListSummary list={aggList} optionsData={aggOptionsData} />
<AggListSummary list={aggList} />
</EuiFormRow>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<PivotPreview aggs={pivotAggs} groupBy={pivotGroupBy} query={pivotQuery} />
<PivotPreview aggs={aggList} groupBy={groupByList} query={pivotQuery} />
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -34,13 +34,15 @@ describe('Data Frame: <PivotPreview />', () => {
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-agg-label',
};
const aggs: PivotAggsConfig[] = [
{ agg: PIVOT_SUPPORTED_AGGS.AVG, field: 'the-agg-field', formRowLabel: 'the-agg-label' },
];
const props = {
aggs,
aggs: { 'the-agg-name': agg },
groupBy: { 'the-group-by-name': groupBy },
query: getPivotQuery('the-query'),
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useContext } from 'react';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
@ -21,13 +21,38 @@ import { dictionaryToArray } from '../../../../common/types/common';
import {
IndexPatternContext,
PivotAggsConfig,
PivotAggsConfigDict,
PivotGroupByConfig,
PivotGroupByConfigDict,
SimpleQuery,
} from '../../common';
import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data';
function sortColumns(groupByArr: PivotGroupByConfig[]) {
return (a: string, b: string) => {
// make sure groupBy fields are always most left columns
if (groupByArr.some(d => d.aggName === a) && groupByArr.some(d => d.aggName === b)) {
return a.localeCompare(b);
}
if (groupByArr.some(d => d.aggName === a)) {
return -1;
}
if (groupByArr.some(d => d.aggName === b)) {
return 1;
}
return a.localeCompare(b);
};
}
function usePrevious(value: any) {
const ref = useRef(null);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
const PreviewTitle = () => (
<EuiTitle size="xs">
<span>
@ -39,12 +64,14 @@ const PreviewTitle = () => (
);
interface Props {
aggs: PivotAggsConfig[];
aggs: PivotAggsConfigDict;
groupBy: PivotGroupByConfigDict;
query: SimpleQuery;
}
export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query }) => {
const [clearTable, setClearTable] = useState(false);
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
@ -58,6 +85,35 @@ export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query
groupBy
);
const groupByArr = dictionaryToArray(groupBy);
// EuiInMemoryTable has an issue with dynamic sortable columns
// and will trigger a full page Kibana error in such a case.
// The following is a workaround until this is solved upstream:
// - If the sortable/columns config changes,
// the table will be unmounted/not rendered.
// This is what the useEffect() part does.
// - After that the table gets re-enabled. To make sure React
// doesn't consolidate the state updates, setTimeout is used.
const firstColumnName =
dataFramePreviewData.length > 0
? Object.keys(dataFramePreviewData[0]).sort(sortColumns(groupByArr))[0]
: undefined;
const firstColumnNameChanged = usePrevious(firstColumnName) !== firstColumnName;
useEffect(() => {
if (firstColumnNameChanged) {
setClearTable(true);
}
if (clearTable) {
setTimeout(() => setClearTable(false), 0);
}
});
if (firstColumnNameChanged) {
return null;
}
if (status === PIVOT_PREVIEW_STATUS.ERROR) {
return (
<EuiPanel grow={false}>
@ -104,21 +160,8 @@ export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query
);
}
const groupByArr = dictionaryToArray(groupBy);
const columnKeys = Object.keys(dataFramePreviewData[0]);
columnKeys.sort((a, b) => {
// make sure groupBy fields are always most left columns
if (groupByArr.some(d => d.formRowLabel === a) && groupByArr.some(d => d.formRowLabel === b)) {
return a.localeCompare(b);
}
if (groupByArr.some(d => d.formRowLabel === a)) {
return -1;
}
if (groupByArr.some(d => d.formRowLabel === b)) {
return 1;
}
return a.localeCompare(b);
});
columnKeys.sort(sortColumns(groupByArr));
const columns = columnKeys.map(k => {
return {
@ -143,7 +186,7 @@ export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query
{status !== PIVOT_PREVIEW_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{dataFramePreviewData.length > 0 && (
{dataFramePreviewData.length > 0 && clearTable === false && (
<EuiInMemoryTable
items={dataFramePreviewData}
columns={columns}

View file

@ -45,7 +45,7 @@ let pivotPreviewObj: UsePivotPreviewDataReturnType;
describe('usePivotPreviewData', () => {
test('indexPattern not defined', () => {
testHook(() => {
pivotPreviewObj = usePivotPreviewData(null, query, [], {});
pivotPreviewObj = usePivotPreviewData(null, query, {}, {});
});
expect(pivotPreviewObj.errorMessage).toBe('');
@ -56,7 +56,7 @@ describe('usePivotPreviewData', () => {
test('indexPattern set triggers loading', () => {
testHook(() => {
pivotPreviewObj = usePivotPreviewData({ title: 'lorem', fields: [] }, query, [], {});
pivotPreviewObj = usePivotPreviewData({ title: 'lorem', fields: [] }, query, {}, {});
});
expect(pivotPreviewObj.errorMessage).toBe('');

View file

@ -13,7 +13,7 @@ import { Dictionary } from '../../../../common/types/common';
import {
getDataFramePreviewRequest,
groupByConfigHasInterval,
PivotAggsConfig,
PivotAggsConfigDict,
PivotGroupByConfigDict,
SimpleQuery,
} from '../../common';
@ -35,7 +35,7 @@ export interface UsePivotPreviewDataReturnType {
export const usePivotPreviewData = (
indexPattern: IndexPatternContextValue,
query: SimpleQuery,
aggs: PivotAggsConfig[],
aggs: PivotAggsConfigDict,
groupBy: PivotGroupByConfigDict
): UsePivotPreviewDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
@ -43,10 +43,11 @@ export const usePivotPreviewData = (
const [dataFramePreviewData, setDataFramePreviewData] = useState([]);
if (indexPattern !== null) {
const aggsArr = dictionaryToArray(aggs);
const groupByArr = dictionaryToArray(groupBy);
const getDataFramePreviewData = async () => {
if (aggs.length === 0 || groupByArr.length === 0) {
if (aggsArr.length === 0 || groupByArr.length === 0) {
setDataFramePreviewData([]);
return;
}
@ -54,7 +55,7 @@ export const usePivotPreviewData = (
setErrorMessage('');
setStatus(PIVOT_PREVIEW_STATUS.LOADING);
const request = getDataFramePreviewRequest(indexPattern.title, query, groupByArr, aggs);
const request = getDataFramePreviewRequest(indexPattern.title, query, groupByArr, aggsArr);
try {
const resp: any = await ml.dataFrame.getDataFrameTransformsPreview(request);
@ -73,13 +74,10 @@ export const usePivotPreviewData = (
},
[
indexPattern.title,
aggs.map(a => `${a.agg} ${a.field} ${a.formRowLabel}`).join(' '),
aggsArr.map(a => `${a.agg} ${a.field} ${a.aggName}`).join(' '),
groupByArr
.map(
g =>
`${g.agg} ${g.field} ${g.formRowLabel} ${
groupByConfigHasInterval(g) ? g.interval : ''
}`
g => `${g.agg} ${g.field} ${g.aggName} ${groupByConfigHasInterval(g) ? g.interval : ''}`
)
.join(' '),
query.query_string.query,

View file

@ -12,7 +12,7 @@ exports[`Data Frame: <GroupByLabelForm /> Date histogram aggregation 1`] = `
<span
className="mlGroupByLabel__text"
>
the-options-data-id
the-group-by-label
</span>
</EuiFlexItem>
<EuiFlexItem
@ -51,9 +51,17 @@ exports[`Data Frame: <GroupByLabelForm /> Date histogram aggregation 1`] = `
panelPaddingSize="m"
>
<PopoverForm
defaultInterval="10m"
intervalType="date_histogram"
defaultData={
Object {
"agg": "date_histogram",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"interval": "10m",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
@ -86,7 +94,7 @@ exports[`Data Frame: <GroupByLabelForm /> Histogram aggregation 1`] = `
<span
className="mlGroupByLabel__text"
>
the-options-data-id
the-group-by-label
</span>
</EuiFlexItem>
<EuiFlexItem
@ -125,9 +133,17 @@ exports[`Data Frame: <GroupByLabelForm /> Histogram aggregation 1`] = `
panelPaddingSize="m"
>
<PopoverForm
defaultInterval="100"
intervalType="histogram"
defaultData={
Object {
"agg": "histogram",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"interval": "100",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
@ -160,9 +176,47 @@ exports[`Data Frame: <GroupByLabelForm /> Terms aggregation 1`] = `
<span
className="mlGroupByLabel__text"
>
the-options-data-id
the-group-by-label
</span>
</EuiFlexItem>
<EuiFlexItem
className="mlGroupByLabel--button"
grow={false}
>
<EuiPopover
anchorPosition="downCenter"
button={
<EuiButtonIcon
aria-label="Edit interval"
color="primary"
iconSize="m"
iconType="pencil"
onClick={[Function]}
size="s"
type="button"
/>
}
closePopover={[Function]}
hasArrow={true}
id="mlIntervalFormPopover"
isOpen={false}
ownFocus={true}
panelPaddingSize="m"
>
<PopoverForm
defaultData={
Object {
"agg": "terms",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
}
}
onChange={[Function]}
options={Object {}}
otherAggNames={Array []}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem
className="mlGroupByLabel--button"
grow={false}

View file

@ -12,12 +12,13 @@ exports[`Data Frame: <GroupByListForm /> Minimal initialization 1`] = `
item={
Object {
"agg": "terms",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"formRowLabel": "the-group-by-label",
}
}
onChange={[Function]}
optionsDataId="the-options-data-id"
options={Object {}}
otherAggNames={Array []}
/>
</EuiPanel>
<EuiSpacer

View file

@ -11,8 +11,8 @@ exports[`Data Frame: <GroupByListSummary /> Minimal initialization 1`] = `
item={
Object {
"agg": "terms",
"aggName": "the-group-by-label",
"field": "the-group-by-field",
"formRowLabel": "the-group-by-label",
}
}
optionsDataId="the-options-data-id"

View file

@ -1,57 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Data Frame: <PopoverForm /> Minimal initialization 1`] = `
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem
grow={false}
style={
Object {
"width": 100,
}
}
exports[`Data Frame: Group By <PopoverForm /> Minimal initialization 1`] = `
<EuiForm
style={
Object {
"width": "300px",
}
}
>
<EuiFormRow
describedByIds={Array []}
error={false}
fullWidth={false}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Group by name"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="the-agg-name"
fullWidth={false}
isInvalid={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
error={false}
fullWidth={false}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Interval"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="10m"
fullWidth={false}
isInvalid={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={true}
labelType="label"
>
<EuiButton
color="primary"
fill={false}
iconSide="left"
isDisabled={false}
onClick={[Function]}
size="m"
type="button"
>
<EuiFormRow
describedByIds={Array []}
error={false}
fullWidth={false}
hasEmptyLabelSpace={false}
isInvalid={false}
label="Interval"
labelType="label"
>
<EuiFieldText
compressed={false}
defaultValue="10m"
fullWidth={false}
isInvalid={false}
isLoading={false}
onChange={[Function]}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiFormRow
describedByIds={Array []}
fullWidth={false}
hasEmptyLabelSpace={true}
labelType="label"
>
<EuiButton
color="primary"
fill={false}
iconSide="left"
isDisabled={false}
onClick={[Function]}
size="m"
type="button"
>
Apply
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
Apply
</EuiButton>
</EuiFormRow>
</EuiForm>
`;

View file

@ -16,12 +16,13 @@ describe('Data Frame: <GroupByLabelForm />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
interval: '10m',
};
const props = {
item,
optionsDataId: 'the-options-data-id',
otherAggNames: [],
options: {},
deleteHandler() {},
onChange() {},
};
@ -35,12 +36,13 @@ describe('Data Frame: <GroupByLabelForm />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
interval: '100',
};
const props = {
item,
optionsDataId: 'the-options-data-id',
otherAggNames: [],
options: {},
deleteHandler() {},
onChange() {},
};
@ -54,11 +56,12 @@ describe('Data Frame: <GroupByLabelForm />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const props = {
item,
optionsDataId: 'the-options-data-id',
otherAggNames: [],
options: {},
deleteHandler() {},
onChange() {},
};

View file

@ -4,80 +4,83 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState } from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiPopover, EuiTextColor } from '@elastic/eui';
import { groupByConfigHasInterval, PivotGroupByConfig } from '../../common';
import {
AggName,
groupByConfigHasInterval,
PivotGroupByConfig,
PivotGroupByConfigDict,
} from '../../common';
import { PopoverForm } from './popover_form';
interface Props {
item: PivotGroupByConfig;
optionsDataId: string;
otherAggNames: AggName[];
options: PivotGroupByConfigDict;
deleteHandler(l: string): void;
onChange(id: string, item: PivotGroupByConfig): void;
onChange(item: PivotGroupByConfig): void;
}
export const GroupByLabelForm: React.SFC<Props> = ({
deleteHandler,
item,
otherAggNames,
onChange,
optionsDataId,
options,
}) => {
const [isPopoverVisible, setPopoverVisibility] = useState(false);
function updateInterval(interval: string) {
if (groupByConfigHasInterval(item)) {
item.interval = interval;
onChange(optionsDataId, item);
setPopoverVisibility(false);
}
function update(updateItem: PivotGroupByConfig) {
onChange({ ...updateItem });
setPopoverVisibility(false);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem className="mlGroupByLabel--text">
<span className="mlGroupByLabel__text">{optionsDataId}</span>
<span className="mlGroupByLabel__text">{item.aggName}</span>
</EuiFlexItem>
{groupByConfigHasInterval(item) && (
<Fragment>
<EuiFlexItem grow={false} className="mlGroupByLabel--text mlGroupByLabel--interval">
<EuiTextColor color="subdued" className="mlGroupByLabel__text">
{item.interval}
</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem grow={false} className="mlGroupByLabel--button">
<EuiPopover
id="mlIntervalFormPopover"
ownFocus
button={
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.ml.dataframe.groupByLabelForm.editIntervalAriaLabel',
{
defaultMessage: 'Edit interval',
}
)}
size="s"
iconType="pencil"
onClick={() => setPopoverVisibility(!isPopoverVisible)}
/>
}
isOpen={isPopoverVisible}
closePopover={() => setPopoverVisibility(false)}
>
<PopoverForm
defaultInterval={item.interval}
intervalType={item.agg}
onChange={updateInterval}
/>
</EuiPopover>
</EuiFlexItem>
</Fragment>
<EuiFlexItem grow={false} className="mlGroupByLabel--text mlGroupByLabel--interval">
<EuiTextColor color="subdued" className="mlGroupByLabel__text">
{item.interval}
</EuiTextColor>
</EuiFlexItem>
)}
<EuiFlexItem grow={false} className="mlGroupByLabel--button">
<EuiPopover
id="mlIntervalFormPopover"
ownFocus
button={
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.ml.dataframe.groupByLabelForm.editIntervalAriaLabel',
{
defaultMessage: 'Edit interval',
}
)}
size="s"
iconType="pencil"
onClick={() => setPopoverVisibility(!isPopoverVisible)}
/>
}
isOpen={isPopoverVisible}
closePopover={() => setPopoverVisibility(false)}
>
<PopoverForm
defaultData={item}
onChange={update}
otherAggNames={otherAggNames}
options={options}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false} className="mlGroupByLabel--button">
<EuiButtonIcon
aria-label={i18n.translate('xpack.ml.dataframe.groupByLabelForm.deleteItemAriaLabel', {
@ -85,7 +88,7 @@ export const GroupByLabelForm: React.SFC<Props> = ({
})}
size="s"
iconType="cross"
onClick={() => deleteHandler(optionsDataId)}
onClick={() => deleteHandler(item.aggName)}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -16,7 +16,7 @@ describe('Data Frame: <GroupByLabelSummary />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
interval: '10m',
};
const props = {
@ -33,7 +33,7 @@ describe('Data Frame: <GroupByLabelSummary />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
interval: '100',
};
const props = {
@ -50,7 +50,7 @@ describe('Data Frame: <GroupByLabelSummary />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const props = {
item,

View file

@ -16,10 +16,11 @@ describe('Data Frame: <GroupByListForm />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const props = {
list: { 'the-options-data-id': item },
options: {},
deleteHandler() {},
onChange() {},
};

View file

@ -8,29 +8,37 @@ import React, { Fragment } from 'react';
import { EuiPanel, EuiSpacer } from '@elastic/eui';
import { PivotGroupByConfig, PivotGroupByConfigDict } from '../../common';
import { AggName, PivotGroupByConfig, PivotGroupByConfigDict } from '../../common';
import { GroupByLabelForm } from './group_by_label_form';
interface ListProps {
list: PivotGroupByConfigDict;
options: PivotGroupByConfigDict;
deleteHandler(l: string): void;
onChange(id: string, item: PivotGroupByConfig): void;
}
export const GroupByListForm: React.SFC<ListProps> = ({ deleteHandler, list, onChange }) => {
export const GroupByListForm: React.SFC<ListProps> = ({
deleteHandler,
list,
onChange,
options,
}) => {
const listKeys = Object.keys(list);
return (
<Fragment>
{listKeys.map((optionsDataId: string) => {
{listKeys.map((aggName: AggName) => {
const otherAggNames = listKeys.filter(k => k !== aggName);
return (
<Fragment key={optionsDataId}>
<Fragment key={aggName}>
<EuiPanel paddingSize="s">
<GroupByLabelForm
deleteHandler={deleteHandler}
item={list[optionsDataId]}
onChange={onChange}
optionsDataId={optionsDataId}
item={list[aggName]}
otherAggNames={otherAggNames}
onChange={item => onChange(aggName, item)}
options={options}
/>
</EuiPanel>
{listKeys.length > 0 && <EuiSpacer size="s" />}

View file

@ -16,7 +16,7 @@ describe('Data Frame: <GroupByListSummary />', () => {
const item: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
formRowLabel: 'the-group-by-label',
aggName: 'the-group-by-label',
};
const props = {
list: { 'the-options-data-id': item },

View file

@ -7,9 +7,9 @@
import { shallow } from 'enzyme';
import React from 'react';
import { PIVOT_SUPPORTED_GROUP_BY_AGGS } from '../../common';
import { AggName, PIVOT_SUPPORTED_GROUP_BY_AGGS, PivotGroupByConfig } from '../../common';
import { isIntervalValid, PopoverForm, supportedIntervalTypes } from './popover_form';
import { isIntervalValid, PopoverForm } from './popover_form';
describe('isIntervalValid()', () => {
test('intervalType: histogram', () => {
@ -73,15 +73,25 @@ describe('isIntervalValid()', () => {
});
});
describe('Data Frame: <PopoverForm />', () => {
describe('Data Frame: Group By <PopoverForm />', () => {
test('Minimal initialization', () => {
const props = {
defaultInterval: '10m',
intervalType: 'date_histogram' as supportedIntervalTypes,
onChange(interval: string) {},
const defaultData: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM,
aggName: 'the-agg-name',
field: 'the-field',
interval: '10m',
};
const otherAggNames: AggName[] = [];
const onChange = (item: PivotGroupByConfig) => {};
const wrapper = shallow(<PopoverForm {...props} />);
const wrapper = shallow(
<PopoverForm
defaultData={defaultData}
otherAggNames={otherAggNames}
options={{}}
onChange={onChange}
/>
);
expect(wrapper).toMatchSnapshot();
});

View file

@ -8,27 +8,28 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
} from '@elastic/eui';
import { EuiButton, EuiFieldText, EuiForm, EuiFormRow, EuiSelect } from '@elastic/eui';
import { dictionaryToArray } from '../../../../common/types/common';
import {
AggName,
dateHistogramIntervalFormatRegex,
groupByConfigHasInterval,
histogramIntervalFormatRegex,
isAggName,
PivotGroupByConfig,
PivotGroupByConfigDict,
PivotSupportedGroupByAggs,
PivotSupportedGroupByAggsWithInterval,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
} from '../../common';
export type supportedIntervalTypes =
| PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM
| PIVOT_SUPPORTED_GROUP_BY_AGGS.DATE_HISTOGRAM;
export function isIntervalValid(interval: string, intervalType: supportedIntervalTypes) {
if (interval !== '') {
export function isIntervalValid(
interval: optionalInterval,
intervalType: PivotSupportedGroupByAggsWithInterval
) {
if (interval !== '' && interval !== undefined) {
if (intervalType === PIVOT_SUPPORTED_GROUP_BY_AGGS.HISTOGRAM) {
if (!histogramIntervalFormatRegex.test(interval)) {
return false;
@ -57,51 +58,154 @@ export function isIntervalValid(interval: string, intervalType: supportedInterva
return false;
}
interface Props {
defaultInterval: string;
intervalType: supportedIntervalTypes;
onChange(interval: string): void;
interface SelectOption {
text: string;
}
export const PopoverForm: React.SFC<Props> = ({ defaultInterval, intervalType, onChange }) => {
const [interval, setInterval] = useState(defaultInterval);
type optionalInterval = string | undefined;
const valid = isIntervalValid(interval, intervalType);
interface Props {
defaultData: PivotGroupByConfig;
otherAggNames: AggName[];
options: PivotGroupByConfigDict;
onChange(item: PivotGroupByConfig): void;
}
export const PopoverForm: React.SFC<Props> = ({
defaultData,
otherAggNames,
onChange,
options,
}) => {
const [agg, setAgg] = useState(defaultData.agg);
const [aggName, setAggName] = useState(defaultData.aggName);
const [field, setField] = useState(defaultData.field);
const [interval, setInterval] = useState(
groupByConfigHasInterval(defaultData) ? defaultData.interval : undefined
);
function getUpdatedItem(): PivotGroupByConfig {
const updatedItem = { ...defaultData, agg, aggName, field };
if (groupByConfigHasInterval(updatedItem) && interval !== undefined) {
updatedItem.interval = interval;
}
// Casting to PivotGroupByConfig because TS would otherwise complain about the
// PIVOT_SUPPORTED_GROUP_BY_AGGS type for `agg`.
return updatedItem as PivotGroupByConfig;
}
const optionsArr = dictionaryToArray(options);
const availableFields: SelectOption[] = optionsArr
.filter(o => o.agg === defaultData.agg)
.map(o => {
return { text: o.field };
});
const availableAggs: SelectOption[] = optionsArr
.filter(o => o.field === defaultData.field)
.map(o => {
return { text: o.agg };
});
let aggNameError = '';
let validAggName = isAggName(aggName);
if (!validAggName) {
aggNameError = i18n.translate(
'xpack.ml.dataframe.groupBy.popoverForm.aggNameInvalidCharError',
{
defaultMessage:
'Invalid name. The characters "[", "]", and ">" are not allowed and the name must not start or end with a space character.',
}
);
}
if (validAggName) {
validAggName = !otherAggNames.includes(aggName);
aggNameError = i18n.translate(
'xpack.ml.dataframe.groupBy.popoverForm.aggNameAlreadyUsedError',
{
defaultMessage: 'Another group by configuration already uses that name.',
}
);
}
const validInterval =
groupByConfigHasInterval(defaultData) && isIntervalValid(interval, defaultData.agg);
let formValid = validAggName;
if (formValid && groupByConfigHasInterval(defaultData)) {
formValid = isIntervalValid(interval, defaultData.agg);
}
return (
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem grow={false} style={{ width: 100 }}>
<EuiFormRow
error={
!valid && [
i18n.translate('xpack.ml.dataframe.popoverForm.intervalError', {
defaultMessage: 'Invalid interval.',
}),
]
}
isInvalid={!valid}
label={i18n.translate('xpack.ml.dataframe.popoverForm.intervalLabel', {
defaultMessage: 'Interval',
})}
>
<EuiFieldText
defaultValue={interval}
isInvalid={!valid}
onChange={e => setInterval(e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow hasEmptyLabelSpace>
<EuiButton isDisabled={!valid} onClick={() => onChange(interval)}>
{i18n.translate('xpack.ml.dataframe.popoverForm.submitButtonLabel', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiForm style={{ width: '300px' }}>
<EuiFormRow
error={!validAggName && [aggNameError]}
isInvalid={!validAggName}
label={i18n.translate('xpack.ml.dataframe.groupBy.popoverForm.nameLabel', {
defaultMessage: 'Group by name',
})}
>
<EuiFieldText
defaultValue={aggName}
isInvalid={!validAggName}
onChange={e => setAggName(e.target.value)}
/>
</EuiFormRow>
{availableAggs.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.groupby.popoverForm.aggLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiSelect
options={availableAggs}
value={agg}
onChange={e => setAgg(e.target.value as PivotSupportedGroupByAggs)}
/>
</EuiFormRow>
)}
{availableFields.length > 0 && (
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.groupBy.popoverForm.fieldLabel', {
defaultMessage: 'Field',
})}
>
<EuiSelect
options={availableFields}
value={field}
onChange={e => setField(e.target.value)}
/>
</EuiFormRow>
)}
{groupByConfigHasInterval(defaultData) && (
<EuiFormRow
error={
!validInterval && [
i18n.translate('xpack.ml.dataframe.groupBy.popoverForm.intervalError', {
defaultMessage: 'Invalid interval.',
}),
]
}
isInvalid={!validInterval}
label={i18n.translate('xpack.ml.dataframe.groupBy.popoverForm.intervalLabel', {
defaultMessage: 'Interval',
})}
>
<EuiFieldText
defaultValue={interval}
isInvalid={!validInterval}
onChange={e => setInterval(e.target.value)}
/>
</EuiFormRow>
)}
<EuiFormRow hasEmptyLabelSpace>
<EuiButton isDisabled={!formValid} onClick={() => onChange(getUpdatedItem())}>
{i18n.translate('xpack.ml.dataframe.groupBy.popoverForm.submitButtonLabel', {
defaultMessage: 'Apply',
})}
</EuiButton>
</EuiFormRow>
</EuiForm>
);
};

View file

@ -89,6 +89,8 @@ interface Props {
}
export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, query }) => {
const [clearTable, setClearTable] = useState(false);
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
@ -98,6 +100,18 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false);
// EuiInMemoryTable has an issue with dynamic sortable columns
// and will trigger a full page Kibana error in such a case.
// The following is a workaround until this is solved upstream:
// - If the sortable/columns config changes,
// the table will be unmounted/not rendered.
// This is what setClearTable(true) in toggleColumn() does.
// - After that on next render it gets re-enabled. To make sure React
// doesn't consolidate the state updates, setTimeout is used.
if (clearTable) {
setTimeout(() => setClearTable(false), 0);
}
function toggleColumnsPopover() {
setColumnsPopoverVisible(!isColumnsPopoverVisible);
}
@ -108,6 +122,7 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
function toggleColumn(column: EsFieldName) {
// spread to a new array otherwise the component wouldn't re-render
setClearTable(true);
setSelectedFields([...toggleSelectedField(selectedFields, column)]);
}
@ -307,17 +322,19 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
{status !== SOURCE_INDEX_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
<ExpandableTable
items={tableItems}
columns={columns}
pagination={true}
hasActions={false}
isSelectable={false}
itemId="_id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
sorting={sorting}
/>
{clearTable === false && (
<ExpandableTable
items={tableItems}
columns={columns}
pagination={true}
hasActions={false}
isSelectable={false}
itemId="_id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
sorting={sorting}
/>
)}
</EuiPanel>
);
});