b01a327076
* feat: 🎸 add ROW_CLICK_TRIGGER * feat: 🎸 wire row click event to UI Actions trigger in Lens * feat: 🎸 add row click trigger to url drilldown * feat: 🎸 add datatable to row click context * feat: 🎸 pass in row index in row click trigger context * feat: 🎸 add columns to row click trigger context * feat: 🎸 fill values and keys event scope array * feat: 🎸 generate correct row scope variables * fix: 🐛 report triggers from lens embeddable * feat: 🎸 add sample preview for row click trigger * feat: 🎸 remove url drilldown preview box * chore: 🤖 remove mock variable generation functions * feat: 🎸 generate context and global variable lists * feat: 🎸 preview event variable list * feat: 🎸 show empty url error on blur * feat: 🎸 add ability to always show popup for executed actions * refactor: 💡 rename multiple action execution method * fix: 🐛 don't add separator befor group on no main items * feat: 🎸 wire in uiActions service into datatable renderer * feat: 🎸 check each row if it has compatible row click actions * feat: 🎸 allow passing data to expression renderer * feat: 🎸 add isEmbeddable helper * feat: 🎸 pass embeddable to lens table renderer * feat: 🎸 hide lens table row actions which are empty * feat: 🎸 re-render lens embeddable when dynamic actions chagne * feat: 🎸 hide actions column if there are no row actions * feat: 🎸 re-render lens embeddable on view mode chagne * fix: 🐛 fix TypeScript errors * chore: 🤖 fix TypeScript errors * docs: ✏️ update auto-generated docs * feat: 🎸 add hasCompatibleActions to expression layer * feat: 🎸 remove "data" from expression renderer handlers * fix: 🐛 fix TypeScript errors * test: 💍 fix Jest tests * docs: ✏️ update autogenerated docs * fix: 🐛 wrap event payload into data * test: 💍 add "alwaysShowPopup" test * chore: 🤖 add comment requested in review https://github.com/elastic/kibana/pull/83167#discussion_r537340216 * test: 💍 add hasCompatibleActions test * test: 💍 add datatable renderer test * test: 💍 add Lens embeddable input change tests * test: 💍 add embeddable row click test * fix: 🐛 add url validation * test: 💍 add url drilldown tests * docs: ✏️ remove url drilldown preview from docs * docs: ✏️ remove preview from url templating * docs: ✏️ add row click description * chore: 🤖 move 36.5 KB bundle balance to url_drilldown * test: 💍 simplify test case * style: 💄 change types places * refactor: 💡 clean up panel variable generation * test: 💍 add getPanelVariables() tests * fix: 🐛 generate runtime variables correctly * fix: 🐛 improve getVariableList() and add tests for it * feat: 🎸 add translation, improve types
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
/*
|
|
* 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 './expression.scss';
|
|
|
|
import React, { useMemo } from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
import { i18n } from '@kbn/i18n';
|
|
import { I18nProvider } from '@kbn/i18n/react';
|
|
import {
|
|
EuiBasicTable,
|
|
EuiFlexGroup,
|
|
EuiButtonIcon,
|
|
EuiFlexItem,
|
|
EuiToolTip,
|
|
EuiBasicTableColumn,
|
|
EuiTableActionsColumnType,
|
|
} from '@elastic/eui';
|
|
import { IAggType } from 'src/plugins/data/public';
|
|
import {
|
|
FormatFactory,
|
|
ILensInterpreterRenderHandlers,
|
|
LensFilterEvent,
|
|
LensMultiTable,
|
|
LensTableRowContextMenuEvent,
|
|
} from '../types';
|
|
import {
|
|
ExpressionFunctionDefinition,
|
|
ExpressionRenderDefinition,
|
|
} from '../../../../../src/plugins/expressions/public';
|
|
import { VisualizationContainer } from '../visualization_container';
|
|
import { EmptyPlaceholder } from '../shared_components';
|
|
import { desanitizeFilterContext } from '../utils';
|
|
import { LensIconChartDatatable } from '../assets/chart_datatable';
|
|
|
|
export interface DatatableColumns {
|
|
columnIds: string[];
|
|
}
|
|
|
|
interface Args {
|
|
title: string;
|
|
description?: string;
|
|
columns: DatatableColumns & { type: 'lens_datatable_columns' };
|
|
}
|
|
|
|
export interface DatatableProps {
|
|
data: LensMultiTable;
|
|
args: Args;
|
|
}
|
|
|
|
type DatatableRenderProps = DatatableProps & {
|
|
formatFactory: FormatFactory;
|
|
onClickValue: (data: LensFilterEvent['data']) => void;
|
|
onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void;
|
|
getType: (name: string) => IAggType;
|
|
|
|
/**
|
|
* A boolean for each table row, which is true if the row active
|
|
* ROW_CLICK_TRIGGER actions attached to it, otherwise false.
|
|
*/
|
|
rowHasRowClickTriggerActions?: boolean[];
|
|
};
|
|
|
|
export interface DatatableRender {
|
|
type: 'render';
|
|
as: 'lens_datatable_renderer';
|
|
value: DatatableProps;
|
|
}
|
|
|
|
export const datatable: ExpressionFunctionDefinition<
|
|
'lens_datatable',
|
|
LensMultiTable,
|
|
Args,
|
|
DatatableRender
|
|
> = {
|
|
name: 'lens_datatable',
|
|
type: 'render',
|
|
inputTypes: ['lens_multitable'],
|
|
help: i18n.translate('xpack.lens.datatable.expressionHelpLabel', {
|
|
defaultMessage: 'Datatable renderer',
|
|
}),
|
|
args: {
|
|
title: {
|
|
types: ['string'],
|
|
help: i18n.translate('xpack.lens.datatable.titleLabel', {
|
|
defaultMessage: 'Title',
|
|
}),
|
|
},
|
|
description: {
|
|
types: ['string'],
|
|
help: '',
|
|
},
|
|
columns: {
|
|
types: ['lens_datatable_columns'],
|
|
help: '',
|
|
},
|
|
},
|
|
fn(data, args) {
|
|
return {
|
|
type: 'render',
|
|
as: 'lens_datatable_renderer',
|
|
value: {
|
|
data,
|
|
args,
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' };
|
|
|
|
export const datatableColumns: ExpressionFunctionDefinition<
|
|
'lens_datatable_columns',
|
|
null,
|
|
DatatableColumns,
|
|
DatatableColumnsResult
|
|
> = {
|
|
name: 'lens_datatable_columns',
|
|
aliases: [],
|
|
type: 'lens_datatable_columns',
|
|
help: '',
|
|
inputTypes: ['null'],
|
|
args: {
|
|
columnIds: {
|
|
types: ['string'],
|
|
multi: true,
|
|
help: '',
|
|
},
|
|
},
|
|
fn: function fn(input: unknown, args: DatatableColumns) {
|
|
return {
|
|
type: 'lens_datatable_columns',
|
|
...args,
|
|
};
|
|
},
|
|
};
|
|
|
|
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',
|
|
}),
|
|
help: '',
|
|
validate: () => undefined,
|
|
reuseDomNode: true,
|
|
render: async (
|
|
domNode: Element,
|
|
config: DatatableProps,
|
|
handlers: ILensInterpreterRenderHandlers
|
|
) => {
|
|
const resolvedFormatFactory = await dependencies.formatFactory;
|
|
const resolvedGetType = await dependencies.getType;
|
|
const onClickValue = (data: LensFilterEvent['data']) => {
|
|
handlers.event({ name: 'filter', data });
|
|
};
|
|
const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => {
|
|
handlers.event({ name: 'tableRowContextMenuClick', data });
|
|
};
|
|
const { hasCompatibleActions } = handlers;
|
|
|
|
// An entry for each table row, whether it has any actions attached to
|
|
// ROW_CLICK_TRIGGER trigger.
|
|
let rowHasRowClickTriggerActions: boolean[] = [];
|
|
if (hasCompatibleActions) {
|
|
const table = Object.values(config.data.tables)[0];
|
|
if (!!table) {
|
|
rowHasRowClickTriggerActions = await Promise.all(
|
|
table.rows.map(async (row, rowIndex) => {
|
|
try {
|
|
const hasActions = await hasCompatibleActions({
|
|
name: 'tableRowContextMenuClick',
|
|
data: {
|
|
rowIndex,
|
|
table,
|
|
columns: config.args.columns.columnIds,
|
|
},
|
|
});
|
|
|
|
return hasActions;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
ReactDOM.render(
|
|
<I18nProvider>
|
|
<DatatableComponent
|
|
{...config}
|
|
formatFactory={resolvedFormatFactory}
|
|
onClickValue={onClickValue}
|
|
onRowContextMenuClick={onRowContextMenuClick}
|
|
getType={resolvedGetType}
|
|
rowHasRowClickTriggerActions={rowHasRowClickTriggerActions}
|
|
/>
|
|
</I18nProvider>,
|
|
domNode,
|
|
() => {
|
|
handlers.done();
|
|
}
|
|
);
|
|
handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
|
|
},
|
|
});
|
|
|
|
export function DatatableComponent(props: DatatableRenderProps) {
|
|
const [firstTable] = Object.values(props.data.tables);
|
|
const formatters: Record<string, ReturnType<FormatFactory>> = {};
|
|
|
|
firstTable.columns.forEach((column) => {
|
|
formatters[column.id] = props.formatFactory(column.meta?.params);
|
|
});
|
|
|
|
const { onClickValue, onRowContextMenuClick } = props;
|
|
const handleFilterClick = useMemo(
|
|
() => (field: string, value: unknown, colIndex: number, negate: boolean = false) => {
|
|
const col = firstTable.columns[colIndex];
|
|
const isDate = col.meta?.type === 'date';
|
|
const timeFieldName = negate && isDate ? undefined : col?.meta?.field;
|
|
const rowIndex = firstTable.rows.findIndex((row) => row[field] === value);
|
|
|
|
const data: LensFilterEvent['data'] = {
|
|
negate,
|
|
data: [
|
|
{
|
|
row: rowIndex,
|
|
column: colIndex,
|
|
value,
|
|
table: firstTable,
|
|
},
|
|
],
|
|
timeFieldName,
|
|
};
|
|
onClickValue(desanitizeFilterContext(data));
|
|
},
|
|
[firstTable, onClickValue]
|
|
);
|
|
|
|
const bucketColumns = firstTable.columns
|
|
.filter((col) => {
|
|
return (
|
|
col?.meta?.sourceParams?.type &&
|
|
props.getType(col.meta.sourceParams.type as string)?.type === 'buckets'
|
|
);
|
|
})
|
|
.map((col) => col.id);
|
|
|
|
const isEmpty =
|
|
firstTable.rows.length === 0 ||
|
|
(bucketColumns.length &&
|
|
firstTable.rows.every((row) =>
|
|
bucketColumns.every((col) => typeof row[col] === 'undefined')
|
|
));
|
|
|
|
if (isEmpty) {
|
|
return <EmptyPlaceholder icon={LensIconChartDatatable} />;
|
|
}
|
|
|
|
const tableColumns: Array<
|
|
EuiBasicTableColumn<{ rowIndex: number; [key: string]: unknown }>
|
|
> = props.args.columns.columnIds
|
|
.map((field) => {
|
|
const col = firstTable.columns.find((c) => c.id === field);
|
|
const filterable = bucketColumns.includes(field);
|
|
const colIndex = firstTable.columns.findIndex((c) => c.id === field);
|
|
return {
|
|
field,
|
|
name: (col && col.name) || '',
|
|
render: (value: unknown) => {
|
|
const formattedValue = formatters[field]?.convert(value);
|
|
const fieldName = col?.meta?.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);
|
|
|
|
if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) {
|
|
const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x);
|
|
if (hasAtLeastOneRowClickAction) {
|
|
const actions: EuiTableActionsColumnType<{ rowIndex: number; [key: string]: unknown }> = {
|
|
name: i18n.translate('xpack.lens.datatable.actionsColumnName', {
|
|
defaultMessage: 'Actions',
|
|
}),
|
|
actions: [
|
|
{
|
|
name: i18n.translate('xpack.lens.tableRowMore', {
|
|
defaultMessage: 'More',
|
|
}),
|
|
description: i18n.translate('xpack.lens.tableRowMoreDescription', {
|
|
defaultMessage: 'Table row context menu',
|
|
}),
|
|
type: 'icon',
|
|
icon: ({ rowIndex }: { rowIndex: number }) => {
|
|
if (
|
|
!!props.rowHasRowClickTriggerActions &&
|
|
!props.rowHasRowClickTriggerActions[rowIndex]
|
|
)
|
|
return 'empty';
|
|
return 'boxesVertical';
|
|
},
|
|
onClick: ({ rowIndex }) => {
|
|
onRowContextMenuClick({
|
|
rowIndex,
|
|
table: firstTable,
|
|
columns: props.args.columns.columnIds,
|
|
});
|
|
},
|
|
},
|
|
],
|
|
};
|
|
tableColumns.push(actions);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<VisualizationContainer
|
|
reportTitle={props.args.title}
|
|
reportDescription={props.args.description}
|
|
>
|
|
<EuiBasicTable
|
|
className="lnsDataTable"
|
|
data-test-subj="lnsDataTable"
|
|
tableLayout="auto"
|
|
columns={tableColumns}
|
|
items={firstTable ? firstTable.rows.map((row, rowIndex) => ({ ...row, rowIndex })) : []}
|
|
/>
|
|
</VisualizationContainer>
|
|
);
|
|
}
|