[7.x] [Lens] New sorting feature for the datatable visualization (#84435) (#85987)

Co-authored-by: Wylie Conlon <wylieconlon@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marco Liberati 2020-12-16 09:56:13 +01:00 committed by GitHub
parent d40ca3388d
commit 91dacb606f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 477 additions and 95 deletions

View file

@ -12,16 +12,19 @@ exports[`datatable_expression DatatableComponent it renders actions column when
"field": "a",
"name": "a",
"render": [Function],
"sortable": true,
},
Object {
"field": "b",
"name": "b",
"render": [Function],
"sortable": true,
},
Object {
"field": "c",
"name": "c",
"render": [Function],
"sortable": true,
},
Object {
"actions": Array [
@ -49,7 +52,14 @@ exports[`datatable_expression DatatableComponent it renders actions column when
]
}
noItemsMessage="No items found"
onChange={[Function]}
responsive={true}
sorting={
Object {
"allowNeutralSort": true,
"sort": undefined,
}
}
tableLayout="auto"
/>
</VisualizationContainer>
@ -67,16 +77,19 @@ exports[`datatable_expression DatatableComponent it renders the title and value
"field": "a",
"name": "a",
"render": [Function],
"sortable": true,
},
Object {
"field": "b",
"name": "b",
"render": [Function],
"sortable": true,
},
Object {
"field": "c",
"name": "c",
"render": [Function],
"sortable": true,
},
]
}
@ -92,7 +105,14 @@ exports[`datatable_expression DatatableComponent it renders the title and value
]
}
noItemsMessage="No items found"
onChange={[Function]}
responsive={true}
sorting={
Object {
"allowNeutralSort": true,
"sort": undefined,
}
}
tableLayout="auto"
/>
</VisualizationContainer>

View file

@ -7,7 +7,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { mountWithIntl } from '@kbn/test/jest';
import { datatable, DatatableComponent } from './expression';
import { getDatatable, DatatableComponent } from './expression';
import { LensMultiTable } from '../types';
import { DatatableProps } from './expression';
import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks';
@ -15,6 +15,7 @@ import { IFieldFormat } from '../../../../../src/plugins/data/public';
import { IAggType } from 'src/plugins/data/public';
import { EmptyPlaceholder } from '../shared_components';
import { LensIconChartDatatable } from '../assets/chart_datatable';
import { EuiBasicTable } from '@elastic/eui';
function sampleArgs() {
const indexPatternId = 'indexPatternId';
@ -67,6 +68,8 @@ function sampleArgs() {
title: 'My fanci metric chart',
columns: {
columnIds: ['a', 'b', 'c'],
sortBy: '',
sortDirection: 'none',
type: 'lens_datatable_columns',
},
};
@ -76,14 +79,21 @@ function sampleArgs() {
describe('datatable_expression', () => {
let onClickValue: jest.Mock;
let onEditAction: jest.Mock;
beforeEach(() => {
onClickValue = jest.fn();
onEditAction = jest.fn();
});
describe('datatable renders', () => {
test('it renders with the specified data and args', () => {
const { data, args } = sampleArgs();
const result = datatable.fn(data, args, createMockExecutionContext());
const result = getDatatable({ formatFactory: (x) => x as IFieldFormat }).fn(
data,
args,
createMockExecutionContext()
);
expect(result).toEqual({
type: 'render',
@ -105,6 +115,7 @@ describe('datatable_expression', () => {
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
getType={jest.fn()}
renderMode="edit"
/>
)
).toMatchSnapshot();
@ -123,6 +134,7 @@ describe('datatable_expression', () => {
getType={jest.fn()}
onRowContextMenuClick={() => undefined}
rowHasRowClickTriggerActions={[true, true, true]}
renderMode="edit"
/>
)
).toMatchSnapshot();
@ -144,6 +156,7 @@ describe('datatable_expression', () => {
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
getType={jest.fn(() => ({ type: 'buckets' } as IAggType))}
renderMode="edit"
/>
);
@ -179,6 +192,7 @@ describe('datatable_expression', () => {
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
getType={jest.fn(() => ({ type: 'buckets' } as IAggType))}
renderMode="edit"
/>
);
@ -232,7 +246,12 @@ describe('datatable_expression', () => {
const args: DatatableProps['args'] = {
title: '',
columns: { columnIds: ['a', 'b'], type: 'lens_datatable_columns' },
columns: {
columnIds: ['a', 'b'],
sortBy: '',
sortDirection: 'none',
type: 'lens_datatable_columns',
},
};
const wrapper = mountWithIntl(
@ -248,6 +267,7 @@ describe('datatable_expression', () => {
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
getType={jest.fn(() => ({ type: 'buckets' } as IAggType))}
renderMode="edit"
/>
);
@ -288,9 +308,90 @@ describe('datatable_expression', () => {
getType={jest.fn((type) =>
type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType)
)}
renderMode="edit"
/>
);
expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable);
});
test('it renders the table with the given sorting', () => {
const { data, args } = sampleArgs();
const wrapper = mountWithIntl(
<DatatableComponent
data={data}
args={{
...args,
columns: {
...args.columns,
sortBy: 'b',
sortDirection: 'desc',
},
}}
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
onEditAction={onEditAction}
getType={jest.fn()}
renderMode="edit"
/>
);
// there's currently no way to detect the sorting column via DOM
expect(
wrapper.exists('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]')
).toBe(true);
// check that the sorting is passing the right next state for the same column
wrapper
.find('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]')
.first()
.simulate('click');
expect(onEditAction).toHaveBeenCalledWith({
action: 'sort',
columnId: undefined,
direction: 'none',
});
// check that the sorting is passing the right next state for another column
wrapper
.find('[data-test-subj="tableHeaderSortButton"]')
.not('[className*="isSorted"]')
.first()
.simulate('click');
expect(onEditAction).toHaveBeenCalledWith({
action: 'sort',
columnId: 'a',
direction: 'asc',
});
});
test('it renders the table with the given sorting in readOnly mode', () => {
const { data, args } = sampleArgs();
const wrapper = mountWithIntl(
<DatatableComponent
data={data}
args={{
...args,
columns: {
...args.columns,
sortBy: 'b',
sortDirection: 'desc',
},
}}
formatFactory={(x) => x as IFieldFormat}
onClickValue={onClickValue}
onEditAction={onEditAction}
getType={jest.fn()}
renderMode="display"
/>
);
expect(wrapper.find(EuiBasicTable).prop('sorting')).toMatchObject({
sort: undefined,
allowNeutralSort: true,
});
});
});
});

View file

@ -16,13 +16,19 @@ import {
EuiButtonIcon,
EuiFlexItem,
EuiToolTip,
Direction,
EuiScreenReaderOnly,
EuiIcon,
EuiBasicTableColumn,
EuiTableActionsColumnType,
} from '@elastic/eui';
import { orderBy } from 'lodash';
import { IAggType } from 'src/plugins/data/public';
import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions';
import {
FormatFactory,
ILensInterpreterRenderHandlers,
LensEditEvent,
LensFilterEvent,
LensMultiTable,
LensTableRowContextMenuEvent,
@ -36,8 +42,22 @@ import { EmptyPlaceholder } from '../shared_components';
import { desanitizeFilterContext } from '../utils';
import { LensIconChartDatatable } from '../assets/chart_datatable';
export const LENS_EDIT_SORT_ACTION = 'sort';
export interface LensSortActionData {
columnId: string | undefined;
direction: 'asc' | 'desc' | 'none';
}
type LensSortAction = LensEditEvent<typeof LENS_EDIT_SORT_ACTION>;
// This is a way to circumvent the explicit "any" forbidden type
type TableRowField = Datatable['rows'][number] & { rowIndex: number };
export interface DatatableColumns {
columnIds: string[];
sortBy: string;
sortDirection: string;
}
interface Args {
@ -54,8 +74,10 @@ export interface DatatableProps {
type DatatableRenderProps = DatatableProps & {
formatFactory: FormatFactory;
onClickValue: (data: LensFilterEvent['data']) => void;
onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void;
onEditAction?: (data: LensSortAction['data']) => void;
getType: (name: string) => IAggType;
renderMode: RenderMode;
onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void;
/**
* A boolean for each table row, which is true if the row active
@ -70,12 +92,11 @@ export interface DatatableRender {
value: DatatableProps;
}
export const datatable: ExpressionFunctionDefinition<
'lens_datatable',
LensMultiTable,
Args,
DatatableRender
> = {
export const getDatatable = ({
formatFactory,
}: {
formatFactory: FormatFactory;
}): ExpressionFunctionDefinition<'lens_datatable', LensMultiTable, Args, DatatableRender> => ({
name: 'lens_datatable',
type: 'render',
inputTypes: ['lens_multitable'],
@ -98,7 +119,40 @@ export const datatable: ExpressionFunctionDefinition<
help: '',
},
},
fn(data, args) {
fn(data, args, context) {
// do the sorting at this level to propagate it also at CSV download
const [firstTable] = Object.values(data.tables);
const [layerId] = Object.keys(context.inspectorAdapters.tables || {});
const formatters: Record<string, ReturnType<FormatFactory>> = {};
firstTable.columns.forEach((column) => {
formatters[column.id] = formatFactory(column.meta?.params);
});
const { sortBy, sortDirection } = args.columns;
const columnsReverseLookup = firstTable.columns.reduce<
Record<string, { name: string; index: number; meta?: DatatableColumnMeta }>
>((memo, { id, name, meta }, i) => {
memo[id] = { name, index: i, meta };
return memo;
}, {});
if (sortBy && sortDirection !== 'none') {
// Sort on raw values for these types, while use the formatted value for the rest
const sortingCriteria = ['number', 'date'].includes(
columnsReverseLookup[sortBy]?.meta?.type || ''
)
? sortBy
: (row: Record<string, unknown>) => formatters[sortBy]?.convert(row[sortBy]);
// replace the table here
context.inspectorAdapters.tables[layerId].rows = orderBy(
firstTable.rows || [],
[sortingCriteria],
sortDirection as Direction
);
// replace also the local copy
firstTable.rows = context.inspectorAdapters.tables[layerId].rows;
}
return {
type: 'render',
as: 'lens_datatable_renderer',
@ -108,7 +162,7 @@ export const datatable: ExpressionFunctionDefinition<
},
};
},
};
});
type DatatableColumnsResult = DatatableColumns & { type: 'lens_datatable_columns' };
@ -124,6 +178,8 @@ export const datatableColumns: ExpressionFunctionDefinition<
help: '',
inputTypes: ['null'],
args: {
sortBy: { types: ['string'], help: '' },
sortDirection: { types: ['string'], help: '' },
columnIds: {
types: ['string'],
multi: true,
@ -139,7 +195,7 @@ export const datatableColumns: ExpressionFunctionDefinition<
};
export const getDatatableRenderer = (dependencies: {
formatFactory: Promise<FormatFactory>;
formatFactory: FormatFactory;
getType: Promise<(name: string) => IAggType>;
}): ExpressionRenderDefinition<DatatableProps> => ({
name: 'lens_datatable_renderer',
@ -154,11 +210,16 @@ export const getDatatableRenderer = (dependencies: {
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 onEditAction = (data: LensSortAction['data']) => {
if (handlers.getRenderMode() === 'edit') {
handlers.event({ name: 'edit', data });
}
};
const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => {
handlers.event({ name: 'tableRowContextMenuClick', data });
};
@ -195,8 +256,10 @@ export const getDatatableRenderer = (dependencies: {
<I18nProvider>
<DatatableComponent
{...config}
formatFactory={resolvedFormatFactory}
formatFactory={dependencies.formatFactory}
onClickValue={onClickValue}
onEditAction={onEditAction}
renderMode={handlers.getRenderMode()}
onRowContextMenuClick={onRowContextMenuClick}
getType={resolvedGetType}
rowHasRowClickTriggerActions={rowHasRowClickTriggerActions}
@ -211,6 +274,45 @@ export const getDatatableRenderer = (dependencies: {
},
});
function getNextOrderValue(currentValue: LensSortAction['data']['direction']) {
const states: Array<LensSortAction['data']['direction']> = ['asc', 'desc', 'none'];
const newStateIndex = (1 + states.findIndex((state) => state === currentValue)) % states.length;
return states[newStateIndex];
}
function getDirectionLongLabel(sortDirection: LensSortAction['data']['direction']) {
if (sortDirection === 'none') {
return sortDirection;
}
return sortDirection === 'asc' ? 'ascending' : 'descending';
}
function getHeaderSortingCell(
name: string,
columnId: string,
sorting: Omit<LensSortAction['data'], 'action'>,
sortingLabel: string
) {
if (columnId !== sorting.columnId || sorting.direction === 'none') {
return name || '';
}
// This is a workaround to hijack the title value of the header cell
return (
<span aria-sort={getDirectionLongLabel(sorting.direction)}>
{name || ''}
<EuiScreenReaderOnly>
<span>{sortingLabel}</span>
</EuiScreenReaderOnly>
<EuiIcon
className="euiTableSortIcon"
type={sorting.direction === 'asc' ? 'sortUp' : 'sortDown'}
size="m"
aria-label={sortingLabel}
/>
</span>
);
}
export function DatatableComponent(props: DatatableRenderProps) {
const [firstTable] = Object.values(props.data.tables);
const formatters: Record<string, ReturnType<FormatFactory>> = {};
@ -219,7 +321,7 @@ export function DatatableComponent(props: DatatableRenderProps) {
formatters[column.id] = props.formatFactory(column.meta?.params);
});
const { onClickValue, onRowContextMenuClick } = props;
const { onClickValue, onEditAction, onRowContextMenuClick } = props;
const handleFilterClick = useMemo(
() => (field: string, value: unknown, colIndex: number, negate: boolean = false) => {
const col = firstTable.columns[colIndex];
@ -264,90 +366,118 @@ export function DatatableComponent(props: DatatableRenderProps) {
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;
const visibleColumns = props.args.columns.columnIds.filter((field) => !!field);
const columnsReverseLookup = firstTable.columns.reduce<
Record<string, { name: string; index: number; meta?: DatatableColumnMeta }>
>((memo, { id, name, meta }, i) => {
memo[id] = { name, index: i, meta };
return memo;
}, {});
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"
const { sortBy, sortDirection } = props.args.columns;
const sortedRows: TableRowField[] =
firstTable?.rows.map((row, rowIndex) => ({ ...row, rowIndex })) || [];
const isReadOnlySorted = props.renderMode !== 'edit';
const sortedInLabel = i18n.translate('xpack.lens.datatableSortedInReadOnlyMode', {
defaultMessage: 'Sorted in {sortValue} order',
values: {
sortValue: sortDirection === 'asc' ? 'ascending' : 'descending',
},
});
const tableColumns: Array<EuiBasicTableColumn<TableRowField>> = visibleColumns.map((field) => {
const filterable = bucketColumns.includes(field);
const { name, index: colIndex, meta } = columnsReverseLookup[field];
const fieldName = meta?.field;
const nameContent = !isReadOnlySorted
? name
: getHeaderSortingCell(
name,
field,
{
columnId: sortBy,
direction: sortDirection as LensSortAction['data']['direction'],
},
sortedInLabel
);
return {
field,
name: nameContent,
sortable: !isReadOnlySorted,
render: (value: unknown) => {
const formattedValue = formatters[field]?.convert(value);
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.includeValueButtonTooltip', {
defaultMessage: 'Include value',
content={i18n.translate('xpack.lens.excludeValueButtonTooltip', {
defaultMessage: 'Exclude value',
})}
>
<EuiButtonIcon
iconType="plusInCircle"
iconType="minusInCircle"
color="text"
aria-label={i18n.translate('xpack.lens.includeValueButtonAriaLabel', {
defaultMessage: `Include {value}`,
aria-label={i18n.translate('xpack.lens.excludeValueButtonAriaLabel', {
defaultMessage: `Exclude {value}`,
values: {
value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`,
},
})}
data-test-subj="lensDatatableFilterFor"
onClick={() => handleFilterClick(field, value, colIndex)}
data-test-subj="lensDatatableFilterOut"
onClick={() => handleFilterClick(field, value, colIndex, true)}
/>
</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);
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return <span data-test-subj="lnsDataTableCellValue">{formattedValue}</span>;
},
};
});
if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) {
const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x);
if (hasAtLeastOneRowClickAction) {
const actions: EuiTableActionsColumnType<{ rowIndex: number; [key: string]: unknown }> = {
const actions: EuiTableActionsColumnType<TableRowField> = {
name: i18n.translate('xpack.lens.datatable.actionsColumnName', {
defaultMessage: 'Actions',
}),
@ -391,8 +521,32 @@ export function DatatableComponent(props: DatatableRenderProps) {
className="lnsDataTable"
data-test-subj="lnsDataTable"
tableLayout="auto"
sorting={{
sort:
!sortBy || sortDirection === 'none' || isReadOnlySorted
? undefined
: {
field: sortBy,
direction: sortDirection as Direction,
},
allowNeutralSort: true, // this flag enables the 3rd Neutral state on the column header
}}
onChange={(event: { sort?: { field: string } }) => {
if (event.sort && onEditAction) {
const isNewColumn = sortBy !== event.sort.field;
// unfortunately the neutral state is not propagated and we need to manually handle it
const nextDirection = getNextOrderValue(
(isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction']
);
return onEditAction({
action: 'sort',
columnId: nextDirection !== 'none' || isNewColumn ? event.sort.field : undefined,
direction: nextDirection,
});
}
}}
columns={tableColumns}
items={firstTable ? firstTable.rows.map((row, rowIndex) => ({ ...row, rowIndex })) : []}
items={sortedRows}
/>
</VisualizationContainer>
);

View file

@ -27,17 +27,18 @@ export class DatatableVisualization {
) {
editorFrame.registerVisualization(async () => {
const {
datatable,
getDatatable,
datatableColumns,
getDatatableRenderer,
datatableVisualization,
} = await import('../async_services');
const resolvedFormatFactory = await formatFactory;
expressions.registerFunction(() => datatableColumns);
expressions.registerFunction(() => datatable);
expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory }));
expressions.registerRenderer(() =>
getDatatableRenderer({
formatFactory,
formatFactory: resolvedFormatFactory,
getType: core
.getStartServices()
.then(([_, { data: dataStart }]) => dataStart.search.aggs.types.get),

View file

@ -306,6 +306,41 @@ describe('Datatable Visualization', () => {
],
});
});
it('should handle correctly the sorting state on removing dimension', () => {
const layer = { layerId: 'layer1', columns: ['b', 'c'] };
expect(
datatableVisualization.removeDimension({
prevState: { layers: [layer], sorting: { columnId: 'b', direction: 'asc' } },
layerId: 'layer1',
columnId: 'b',
})
).toEqual({
sorting: undefined,
layers: [
{
layerId: 'layer1',
columns: ['c'],
},
],
});
expect(
datatableVisualization.removeDimension({
prevState: { layers: [layer], sorting: { columnId: 'c', direction: 'asc' } },
layerId: 'layer1',
columnId: 'b',
})
).toEqual({
sorting: { columnId: 'c', direction: 'asc' },
layers: [
{
layerId: 'layer1',
columns: ['c'],
},
],
});
});
});
describe('#setDimension', () => {
@ -371,6 +406,8 @@ describe('Datatable Visualization', () => {
expect(tableArgs).toHaveLength(1);
expect(tableArgs[0].arguments).toEqual({
columnIds: ['c', 'b'],
sortBy: [''],
sortDirection: ['none'],
});
});

View file

@ -22,6 +22,10 @@ export interface LayerState {
export interface DatatableVisualizationState {
layers: LayerState[];
sorting?: {
columnId: string | undefined;
direction: 'asc' | 'desc' | 'none';
};
}
function newLayerState(layerId: string): LayerState {
@ -196,6 +200,7 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
}
: l
),
sorting: prevState.sorting?.columnId === columnId ? undefined : prevState.sorting,
};
},
@ -232,6 +237,8 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
function: 'lens_datatable_columns',
arguments: {
columnIds: operations.map((o) => o.columnId),
sortBy: [state.sorting?.columnId || ''],
sortDirection: [state.sorting?.direction || 'none'],
},
},
],
@ -246,6 +253,19 @@ export const datatableVisualization: Visualization<DatatableVisualizationState>
getErrorMessages(state, frame) {
return undefined;
},
onEditAction(state, event) {
if (event.data.action !== 'sort') {
return state;
}
return {
...state,
sorting: {
columnId: event.data.columnId,
direction: event.data.direction,
},
};
},
};
function getDataSourceAndSortedColumns(

View file

@ -42,7 +42,7 @@ function LayerPanels(
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState,
updater: newState,
clearStagedPreview: false,
});
},

View file

@ -126,7 +126,7 @@ export function EditorFrame(props: EditorFrameProps) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState: layerIds.reduce(
updater: layerIds.reduce(
(acc, layerId) =>
activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc,
state.visualization.state
@ -187,7 +187,7 @@ export function EditorFrame(props: EditorFrameProps) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState: initialVisualizationState,
updater: initialVisualizationState,
});
}
},

View file

@ -129,7 +129,7 @@ describe('editor_frame state management', () => {
{
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: 'testVis',
newState: newVisState,
updater: newVisState,
}
);

View file

@ -54,7 +54,7 @@ export type Action =
| {
type: 'UPDATE_VISUALIZATION_STATE';
visualizationId: string;
newState: unknown;
updater: unknown | ((state: unknown) => unknown);
clearStagedPreview?: boolean;
}
| {
@ -282,7 +282,10 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
...state,
visualization: {
...state.visualization,
state: action.newState,
state:
typeof action.updater === 'function'
? action.updater(state.visualization.state)
: action.updater,
},
stagedPreview: action.clearStagedPreview ? undefined : state.stagedPreview,
};

View file

@ -37,6 +37,7 @@ import {
FramePublicAPI,
isLensBrushEvent,
isLensFilterEvent,
isLensEditEvent,
} from '../../../types';
import { DragDrop, DragContext } from '../../../drag_drop';
import { getSuggestions, switchToSuggestion } from '../suggestion_helpers';
@ -217,8 +218,15 @@ export function WorkspacePanel({
data: event.data,
});
}
if (isLensEditEvent(event) && activeVisualization?.onEditAction) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
updater: (oldState: unknown) => activeVisualization.onEditAction!(oldState, event),
});
}
},
[plugins.uiActions]
[plugins.uiActions, dispatch, activeVisualization]
);
useEffect(() => {
@ -472,6 +480,7 @@ export const InnerVisualizationWrapper = ({
reload$={autoRefreshFetch$}
onEvent={onEvent}
onData$={onData$}
renderMode="edit"
renderError={(errorMessage?: string | null, error?: ExpressionRenderError | null) => {
const visibleErrorMessage = getOriginalRequestErrorMessage(error) || errorMessage;

View file

@ -59,7 +59,7 @@ export function WorkspacePanelWrapper({
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState,
updater: newState,
clearStagedPreview: false,
});
},

View file

@ -26,6 +26,10 @@ import {
VALUE_CLICK_TRIGGER,
VisualizeFieldContext,
} from '../../../../src/plugins/ui_actions/public';
import type {
LensSortActionData,
LENS_EDIT_SORT_ACTION,
} from './datatable_visualization/expression';
export type ErrorCallback = (e: { message: string }) => void;
@ -609,6 +613,11 @@ export interface Visualization<T = unknown> {
* The frame calls this function to display warnings about visualization
*/
getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
/**
* On Edit events the frame will call this to know what's going to be the next visualization state
*/
onEditAction?: (state: T, event: LensEditEvent<LensEditSupportedActions>) => T;
}
export interface LensFilterEvent {
@ -621,6 +630,22 @@ export interface LensBrushEvent {
data: TriggerContext<typeof SELECT_RANGE_TRIGGER>['data'];
}
// Use same technique as TriggerContext
interface LensEditContextMapping {
[LENS_EDIT_SORT_ACTION]: LensSortActionData;
}
type LensEditSupportedActions = keyof LensEditContextMapping;
export type LensEditPayload<T extends LensEditSupportedActions> = {
action: T;
} & LensEditContextMapping[T];
type EditPayloadContext<T> = T extends LensEditSupportedActions ? LensEditPayload<T> : never;
export interface LensEditEvent<T> {
name: 'edit';
data: EditPayloadContext<T>;
}
export interface LensTableRowContextMenuEvent {
name: 'tableRowContextMenuClick';
data: TriggerContext<typeof ROW_CLICK_TRIGGER>['data'];
@ -634,6 +659,12 @@ export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensB
return event.name === 'brush';
}
export function isLensEditEvent<T extends LensEditSupportedActions>(
event: ExpressionRendererEvent
): event is LensEditEvent<T> {
return event.name === 'edit';
}
export function isLensTableRowContextMenuClickEvent(
event: ExpressionRendererEvent
): event is LensBrushEvent {
@ -646,5 +677,11 @@ export function isLensTableRowContextMenuClickEvent(
* used, dispatched events will be handled correctly.
*/
export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers {
event: (event: LensFilterEvent | LensBrushEvent | LensTableRowContextMenuEvent) => void;
event: (
event:
| LensFilterEvent
| LensBrushEvent
| LensEditEvent<LensEditSupportedActions>
| LensTableRowContextMenuEvent
) => void;
}