mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Lens] New sorting feature for the datatable visualization (#84435)
Co-authored-by: Wylie Conlon <wylieconlon@gmail.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
a57cba4978
commit
62623cdab9
13 changed files with 477 additions and 95 deletions
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -42,7 +42,7 @@ function LayerPanels(
|
|||
dispatch({
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
visualizationId: activeVisualization.id,
|
||||
newState,
|
||||
updater: newState,
|
||||
clearStagedPreview: false,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
@ -129,7 +129,7 @@ describe('editor_frame state management', () => {
|
|||
{
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
visualizationId: 'testVis',
|
||||
newState: newVisState,
|
||||
updater: newVisState,
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ export function WorkspacePanelWrapper({
|
|||
dispatch({
|
||||
type: 'UPDATE_VISUALIZATION_STATE',
|
||||
visualizationId: activeVisualization.id,
|
||||
newState,
|
||||
updater: newState,
|
||||
clearStagedPreview: false,
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue