[Lens] Trigger a filter action on click in datatable visualization (#63840)

* wip: datatable

* fix: empty values

* fix: empty values

* translations

* using dataPlugin to get buckets

* one more time, passing aggs data

* tests: added

* feat: new design applied

* remove icon

* feat: old design

* CR corrections

* better name

* Fix merge issue

* fix: design changes

* feat: correction

* fix: copy changes

* Update x-pack/plugins/lens/public/datatable_visualization/_visualization.scss

Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com>

* Update _visualization.scss

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Wylie Conlon <wylieconlon@gmail.com>
Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2020-04-30 23:44:16 +02:00 committed by GitHub
parent 47b8ba5d5b
commit c4e6789c28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 373 additions and 29 deletions

View file

@ -0,0 +1,41 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`datatable_expression DatatableComponent it renders the title and value 1`] = `
<VisualizationContainer>
<EuiBasicTable
className="lnsDataTable"
columns={
Array [
Object {
"field": "a",
"name": "a",
"render": [Function],
},
Object {
"field": "b",
"name": "b",
"render": [Function],
},
Object {
"field": "c",
"name": "c",
"render": [Function],
},
]
}
data-test-subj="lnsDataTable"
items={
Array [
Object {
"a": 10110,
"b": 1588024800000,
"c": 3,
},
]
}
noItemsMessage="No items found"
responsive={true}
tableLayout="auto"
/>
</VisualizationContainer>
`;

View file

@ -1,3 +1,13 @@
.lnsDataTable {
align-self: flex-start;
}
.lnsDataTable__filter {
opacity: 0;
transition: opacity $euiAnimSpeedNormal ease-in-out;
}
.lnsDataTable__cell:hover .lnsDataTable__filter,
.lnsDataTable__filter:focus-within {
opacity: 1;
}

View file

@ -0,0 +1,156 @@
/*
* 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 from 'react';
import { shallow } from 'enzyme';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { datatable, DatatableComponent } from './expression';
import { LensMultiTable } from '../types';
import { DatatableProps } from './expression';
import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks';
import { IFieldFormat } from '../../../../../src/plugins/data/public';
import { IAggType } from 'src/plugins/data/public';
const executeTriggerActions = jest.fn();
function sampleArgs() {
const data: LensMultiTable = {
type: 'lens_multitable',
tables: {
l1: {
type: 'kibana_datatable',
columns: [
{ id: 'a', name: 'a', meta: { type: 'count' } },
{ id: 'b', name: 'b', meta: { type: 'date_histogram', aggConfigParams: { field: 'b' } } },
{ id: 'c', name: 'c', meta: { type: 'cardinality' } },
],
rows: [{ a: 10110, b: 1588024800000, c: 3 }],
},
},
};
const args: DatatableProps['args'] = {
title: 'My fanci metric chart',
columns: {
columnIds: ['a', 'b', 'c'],
type: 'lens_datatable_columns',
},
};
return { data, args };
}
describe('datatable_expression', () => {
describe('datatable renders', () => {
test('it renders with the specified data and args', () => {
const { data, args } = sampleArgs();
const result = datatable.fn(data, args, createMockExecutionContext());
expect(result).toEqual({
type: 'render',
as: 'lens_datatable_renderer',
value: { data, args },
});
});
});
describe('DatatableComponent', () => {
test('it renders the title and value', () => {
const { data, args } = sampleArgs();
expect(
shallow(
<DatatableComponent
data={data}
args={args}
formatFactory={x => x as IFieldFormat}
executeTriggerActions={executeTriggerActions}
getType={jest.fn()}
/>
)
).toMatchSnapshot();
});
test('it invokes executeTriggerActions with correct context on click on top value', () => {
const { args, data } = sampleArgs();
const wrapper = mountWithIntl(
<DatatableComponent
data={{
...data,
dateRange: {
fromDate: new Date('2020-04-20T05:00:00.000Z'),
toDate: new Date('2020-05-03T05:00:00.000Z'),
},
}}
args={args}
formatFactory={x => x as IFieldFormat}
executeTriggerActions={executeTriggerActions}
getType={jest.fn(() => ({ type: 'buckets' } as IAggType))}
/>
);
wrapper
.find('[data-test-subj="lensDatatableFilterOut"]')
.first()
.simulate('click');
expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', {
data: {
data: [
{
column: 0,
row: 0,
table: data.tables.l1,
value: 10110,
},
],
negate: true,
},
timeFieldName: undefined,
});
});
test('it invokes executeTriggerActions with correct context on click on timefield', () => {
const { args, data } = sampleArgs();
const wrapper = mountWithIntl(
<DatatableComponent
data={{
...data,
dateRange: {
fromDate: new Date('2020-04-20T05:00:00.000Z'),
toDate: new Date('2020-05-03T05:00:00.000Z'),
},
}}
args={args}
formatFactory={x => x as IFieldFormat}
executeTriggerActions={executeTriggerActions}
getType={jest.fn(() => ({ type: 'buckets' } as IAggType))}
/>
);
wrapper
.find('[data-test-subj="lensDatatableFilterFor"]')
.at(3)
.simulate('click');
expect(executeTriggerActions).toHaveBeenCalledWith('VALUE_CLICK_TRIGGER', {
data: {
data: [
{
column: 1,
row: 0,
table: data.tables.l1,
value: 1588024800000,
},
],
negate: false,
},
timeFieldName: 'b',
});
});
});
});

View file

@ -7,7 +7,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import { EuiBasicTable } from '@elastic/eui';
import { I18nProvider } from '@kbn/i18n/react';
import { EuiBasicTable, EuiFlexGroup, EuiButtonIcon, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { IAggType } from 'src/plugins/data/public';
import { FormatFactory, LensMultiTable } from '../types';
import {
ExpressionFunctionDefinition,
@ -15,7 +17,10 @@ import {
IInterpreterRenderHandlers,
} from '../../../../../src/plugins/expressions/public';
import { VisualizationContainer } from '../visualization_container';
import { ValueClickTriggerContext } from '../../../../../src/plugins/embeddable/public';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../src/plugins/visualizations/public';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { getExecuteTriggerActions } from '../services';
export interface DatatableColumns {
columnIds: string[];
}
@ -30,6 +35,12 @@ export interface DatatableProps {
args: Args;
}
type DatatableRenderProps = DatatableProps & {
formatFactory: FormatFactory;
executeTriggerActions: UiActionsStart['executeTriggerActions'];
getType: (name: string) => IAggType;
};
export interface DatatableRender {
type: 'render';
as: 'lens_datatable_renderer';
@ -100,9 +111,10 @@ export const datatableColumns: ExpressionFunctionDefinition<
},
};
export const getDatatableRenderer = (
formatFactory: Promise<FormatFactory>
): ExpressionRenderDefinition<DatatableProps> => ({
export const getDatatableRenderer = (dependencies: {
formatFactory: Promise<FormatFactory>;
getType: Promise<(name: string) => IAggType>;
}): ExpressionRenderDefinition<DatatableProps> => ({
name: 'lens_datatable_renderer',
displayName: i18n.translate('xpack.lens.datatable.visualizationName', {
defaultMessage: 'Datatable',
@ -115,9 +127,18 @@ export const getDatatableRenderer = (
config: DatatableProps,
handlers: IInterpreterRenderHandlers
) => {
const resolvedFormatFactory = await formatFactory;
const resolvedFormatFactory = await dependencies.formatFactory;
const executeTriggerActions = getExecuteTriggerActions();
const resolvedGetType = await dependencies.getType;
ReactDOM.render(
<DatatableComponent {...config} formatFactory={resolvedFormatFactory} />,
<I18nProvider>
<DatatableComponent
{...config}
formatFactory={resolvedFormatFactory}
executeTriggerActions={executeTriggerActions}
getType={resolvedGetType}
/>
</I18nProvider>,
domNode,
() => {
handlers.done();
@ -127,7 +148,7 @@ export const getDatatableRenderer = (
},
});
function DatatableComponent(props: DatatableProps & { formatFactory: FormatFactory }) {
export function DatatableComponent(props: DatatableRenderProps) {
const [firstTable] = Object.values(props.data.tables);
const formatters: Record<string, ReturnType<FormatFactory>> = {};
@ -135,6 +156,29 @@ function DatatableComponent(props: DatatableProps & { formatFactory: FormatFacto
formatters[column.id] = props.formatFactory(column.formatHint);
});
const handleFilterClick = (field: string, value: unknown, colIndex: number, negate = false) => {
const col = firstTable.columns[colIndex];
const isDateHistogram = col.meta?.type === 'date_histogram';
const timeFieldName = negate && isDateHistogram ? undefined : col?.meta?.aggConfigParams?.field;
const rowIndex = firstTable.rows.findIndex(row => row[field] === value);
const context: ValueClickTriggerContext = {
data: {
negate,
data: [
{
row: rowIndex,
column: colIndex,
value,
table: firstTable,
},
],
},
timeFieldName,
};
props.executeTriggerActions(VIS_EVENT_TO_TRIGGER.filter, context);
};
return (
<VisualizationContainer>
<EuiBasicTable
@ -144,23 +188,87 @@ function DatatableComponent(props: DatatableProps & { formatFactory: FormatFacto
columns={props.args.columns.columnIds
.map(field => {
const col = firstTable.columns.find(c => c.id === field);
const colIndex = firstTable.columns.findIndex(c => c.id === field);
const filterable = col?.meta?.type && props.getType(col.meta.type)?.type === 'buckets';
return {
field,
name: (col && col.name) || '',
render: (value: unknown) => {
const formattedValue = formatters[field]?.convert(value);
const fieldName = col?.meta?.aggConfigParams?.field;
if (filterable) {
return (
<EuiFlexGroup
className="lnsDataTable__cell"
data-test-subj="lnsDataTableCellValueFilterable"
gutterSize="xs"
>
<EuiFlexItem grow={false}>{formattedValue}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup
responsive={false}
gutterSize="none"
alignItems="center"
className="lnsDataTable__filter"
>
<EuiToolTip
position="bottom"
content={i18n.translate('xpack.lens.includeValueButtonTooltip', {
defaultMessage: 'Include value',
})}
>
<EuiButtonIcon
iconType="plusInCircle"
color="text"
aria-label={i18n.translate('xpack.lens.includeValueButtonAriaLabel', {
defaultMessage: `Include {value}`,
values: {
value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`,
},
})}
data-test-subj="lensDatatableFilterFor"
onClick={() => handleFilterClick(field, value, colIndex)}
/>
</EuiToolTip>
<EuiFlexItem grow={false}>
<EuiToolTip
position="bottom"
content={i18n.translate('xpack.lens.excludeValueButtonTooltip', {
defaultMessage: 'Exclude value',
})}
>
<EuiButtonIcon
iconType="minusInCircle"
color="text"
aria-label={i18n.translate(
'xpack.lens.excludeValueButtonAriaLabel',
{
defaultMessage: `Exclude {value}`,
values: {
value: `${
fieldName ? `${fieldName}: ` : ''
}${formattedValue}`,
},
}
)}
data-test-subj="lensDatatableFilterOut"
onClick={() => handleFilterClick(field, value, colIndex, true)}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return <span data-test-subj="lnsDataTableCellValue">{formattedValue}</span>;
},
};
})
.filter(({ field }) => !!field)}
items={
firstTable
? firstTable.rows.map(row => {
const formattedRow: Record<string, unknown> = {};
Object.entries(formatters).forEach(([columnId, formatter]) => {
formattedRow[columnId] = formatter.convert(row[columnId]);
});
return formattedRow;
})
: []
}
items={firstTable ? firstTable.rows : []}
/>
</VisualizationContainer>
);

View file

@ -4,12 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup } from 'kibana/public';
import { CoreSetup, CoreStart } from 'kibana/public';
import { datatableVisualization } from './visualization';
import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public';
import { datatable, datatableColumns, getDatatableRenderer } from './expression';
import { EditorFrameSetup, FormatFactory } from '../types';
import { setExecuteTriggerActions } from '../services';
import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
interface DatatableVisualizationPluginStartPlugins {
uiActions: UiActionsStart;
data: DataPublicPluginStart;
}
export interface DatatableVisualizationPluginSetupPlugins {
expressions: ExpressionsSetup;
formatFactory: Promise<FormatFactory>;
@ -20,12 +27,22 @@ export class DatatableVisualization {
constructor() {}
setup(
_core: CoreSetup | null,
core: CoreSetup<DatatableVisualizationPluginStartPlugins, void>,
{ expressions, formatFactory, editorFrame }: DatatableVisualizationPluginSetupPlugins
) {
expressions.registerFunction(() => datatableColumns);
expressions.registerFunction(() => datatable);
expressions.registerRenderer(() => getDatatableRenderer(formatFactory));
expressions.registerRenderer(() =>
getDatatableRenderer({
formatFactory,
getType: core
.getStartServices()
.then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get),
})
);
editorFrame.registerVisualization(datatableVisualization);
}
start(core: CoreStart, { uiActions }: DatatableVisualizationPluginStartPlugins) {
setExecuteTriggerActions(uiActions.executeTriggerActions);
}
}

View file

@ -200,6 +200,7 @@ export class LensPlugin {
start(core: CoreStart, startDependencies: LensPluginStartDependencies) {
this.createEditorFrame = this.editorFrameService.start(core, startDependencies).createInstance;
this.xyVisualization.start(core, startDependencies);
this.datatableVisualization.start(core, startDependencies);
}
stop() {

View file

@ -26,12 +26,12 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
async function assertExpectedMetric() {
async function assertExpectedMetric(metricCount: string = '19,986') {
await PageObjects.lens.assertExactText(
'[data-test-subj="lns_metric_title"]',
'Maximum of bytes'
);
await PageObjects.lens.assertExactText('[data-test-subj="lns_metric_value"]', '19,986');
await PageObjects.lens.assertExactText('[data-test-subj="lns_metric_value"]', metricCount);
}
async function assertExpectedTable() {
@ -40,8 +40,12 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
'Maximum of bytes'
);
await PageObjects.lens.assertExactText(
'[data-test-subj="lnsDataTable"] tbody .euiTableCellContent__text',
'19,986'
'[data-test-subj="lnsDataTable"] [data-test-subj="lnsDataTableCellValue"]',
'19,985'
);
await PageObjects.lens.assertExactText(
'[data-test-subj="lnsDataTable"] [data-test-subj="lnsDataTableCellValueFilterable"]',
'IN'
);
}
@ -86,7 +90,7 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
await assertExpectedMetric();
});
it('click on the bar in XYChart adds proper filters/timerange', async () => {
it('click on the bar in XYChart adds proper filters/timerange in dashboard', async () => {
await PageObjects.common.navigateToApp('dashboard');
await PageObjects.dashboard.clickNewDashboard();
await dashboardAddPanel.clickOpenAddPanel();
@ -102,15 +106,22 @@ export default function({ getService, getPageObjects }: FtrProviderContext) {
expect(hasIpFilter).to.be(true);
});
it('should allow seamless transition to and from table view', async () => {
it('should allow seamless transition to and from table view and add a filter', async () => {
await PageObjects.visualize.gotoVisualizationLandingPage();
await PageObjects.lens.clickVisualizeListItemTitle('Artistpreviouslyknownaslens');
await PageObjects.lens.goToTimeRange();
await assertExpectedMetric();
await PageObjects.lens.switchToVisualization('lnsChartSwitchPopover_lnsDatatable');
await PageObjects.lens.configureDimension({
dimension: '[data-test-subj="lnsDatatable_column"] [data-test-subj="lns-empty-dimension"]',
operation: 'terms',
field: 'geo.dest',
});
await PageObjects.lens.save('Artistpreviouslyknownaslens');
await find.clickByCssSelector('[data-test-subj="lensDatatableFilterOut"]');
await assertExpectedTable();
await PageObjects.lens.switchToVisualization('lnsChartSwitchPopover_lnsMetric');
await assertExpectedMetric();
await assertExpectedMetric('19,985');
});
it('should allow creation of lens visualizations', async () => {