[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:
parent
47b8ba5d5b
commit
c4e6789c28
41
x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap
generated
Normal file
41
x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap
generated
Normal 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>
|
||||
`;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
Loading…
Reference in a new issue