[Lens] Supports multi rows headers for the table visualization (#127447)

* [Lens] Supports multi rows headers

* Apply design changes

* Use emotion instead

* column styling implementation entirely with emotion

* Merge the 2 funstions into one

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2022-03-17 11:00:53 +02:00 committed by GitHub
parent e71b9b1566
commit 4e63701fcb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 322 additions and 109 deletions

View file

@ -29,6 +29,8 @@ export interface DatatableArgs {
sortingDirection: SortingState['direction'];
fitRowToContent?: boolean;
rowHeightLines?: number;
headerRowHeight?: 'auto' | 'single' | 'custom';
headerRowHeightLines?: number;
pageSize?: PagingState['size'];
}
@ -73,6 +75,14 @@ export const getDatatable = (
types: ['number'],
help: '',
},
headerRowHeight: {
types: ['string'],
help: '',
},
headerRowHeightLines: {
types: ['number'],
help: '',
},
pageSize: {
types: ['number'],
help: '',

View file

@ -126,7 +126,15 @@ exports[`DatatableComponent it renders actions column when there are row actions
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
a
</div>,
@ -167,7 +175,15 @@ exports[`DatatableComponent it renders actions column when there are row actions
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
b
</div>,
@ -208,7 +224,15 @@ exports[`DatatableComponent it renders actions column when there are row actions
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--right"
css={
Object {
"map": undefined,
"name": "s2uf1z",
"next": undefined,
"styles": "text-align:right;",
"toString": [Function],
}
}
>
c
</div>,
@ -378,7 +402,15 @@ exports[`DatatableComponent it renders the title and value 1`] = `
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
a
</div>,
@ -419,7 +451,15 @@ exports[`DatatableComponent it renders the title and value 1`] = `
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
b
</div>,
@ -460,7 +500,15 @@ exports[`DatatableComponent it renders the title and value 1`] = `
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--right"
css={
Object {
"map": undefined,
"name": "s2uf1z",
"next": undefined,
"styles": "text-align:right;",
"toString": [Function],
}
}
>
c
</div>,
@ -625,7 +673,15 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
a
</div>,
@ -666,7 +722,15 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--left"
css={
Object {
"map": undefined,
"name": "13brihr",
"next": undefined,
"styles": "text-align:left;",
"toString": [Function],
}
}
>
b
</div>,
@ -707,7 +771,15 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he
},
"cellActions": undefined,
"display": <div
className="lnsTableCell--right"
css={
Object {
"map": undefined,
"name": "s2uf1z",
"next": undefined,
"styles": "text-align:right;",
"toString": [Function],
}
}
>
c
</div>,

View file

@ -7,6 +7,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import {
EuiDataGridColumn,
EuiDataGridColumnCellActionProps,
@ -40,7 +41,9 @@ export const createGridColumns = (
formatFactory: FormatFactory,
onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void,
onColumnHide: ((eventData: { columnId: string }) => void) | undefined,
alignments: Record<string, 'left' | 'right' | 'center'>
alignments: Record<string, 'left' | 'right' | 'center'>,
headerRowHeight: 'auto' | 'single' | 'custom',
headerRowLines: number
) => {
const columnsReverseLookup = table.columns.reduce<
Record<string, { name: string; index: number; meta?: DatatableColumnMeta }>
@ -209,12 +212,24 @@ export const createGridColumns = (
}
}
const currentAlignment = alignments && alignments[field];
const alignmentClassName = `lnsTableCell--${currentAlignment}`;
const hasMultipleRows = headerRowHeight === 'auto' || headerRowHeight === 'custom';
const columnStyle = css({
...(headerRowHeight === 'custom' && {
WebkitLineClamp: headerRowLines,
}),
...(hasMultipleRows && {
whiteSpace: 'normal',
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
}),
textAlign: currentAlignment,
});
const columnDefinition: EuiDataGridColumn = {
id: field,
cellActions,
display: <div className={alignmentClassName}>{name}</div>,
display: <div css={columnStyle}>{name}</div>,
displayAsText: name,
actions: {
showHide: false,

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonGroup, EuiFormRow, EuiRange, htmlIdGenerator, EuiSpacer } from '@elastic/eui';
import type { DatatableVisualizationState } from '../visualization';
export interface RowHeightSettingsProps {
rowHeight?: 'auto' | 'single' | 'custom';
rowHeightLines?: number;
maxRowHeight?: number;
label: string;
onChangeRowHeight: (newHeightMode: 'auto' | 'single' | 'custom' | undefined) => void;
onChangeRowHeightLines: (newRowHeightLines: number) => void;
'data-test-subj'?: string;
}
const idPrefix = htmlIdGenerator()();
export function RowHeightSettings(props: RowHeightSettingsProps) {
const {
label,
rowHeight,
rowHeightLines,
onChangeRowHeight,
onChangeRowHeightLines,
maxRowHeight,
} = props;
const rowHeightModeOptions = [
{
id: `${idPrefix}single`,
label: i18n.translate('xpack.lens.table.rowHeight.single', {
defaultMessage: 'Single',
}),
'data-test-subj': 'lnsDatatable_rowHeight_single',
},
{
id: `${idPrefix}auto`,
label: i18n.translate('xpack.lens.table.rowHeight.auto', {
defaultMessage: 'Auto fit',
}),
'data-test-subj': 'lnsDatatable_rowHeight_auto',
},
{
id: `${idPrefix}custom`,
label: i18n.translate('xpack.lens.table.rowHeight.custom', {
defaultMessage: 'Custom',
}),
'data-test-subj': 'lnsDatatable_rowHeight_custom',
},
];
return (
<>
<EuiFormRow label={label} display="columnCompressed" data-test-subj={props['data-test-subj']}>
<>
<EuiButtonGroup
isFullWidth
legend={label}
name="legendLocation"
buttonSize="compressed"
options={rowHeightModeOptions}
idSelected={`${idPrefix}${rowHeight ?? 'single'}`}
onChange={(optionId) => {
const newMode = optionId.replace(
idPrefix,
''
) as DatatableVisualizationState['rowHeight'];
onChangeRowHeight(newMode);
}}
/>
{rowHeight === 'custom' ? (
<>
<EuiSpacer size="xs" />
<EuiRange
compressed
fullWidth
showInput
min={1}
max={maxRowHeight ?? 20}
step={1}
value={rowHeightLines ?? 2}
onChange={(e) => {
const lineCount = Number(e.currentTarget.value);
onChangeRowHeightLines(lineCount);
}}
data-test-subj="lens-table-row-height-lineCountNumber"
/>
</>
) : null}
</>
</EuiFormRow>
</>
);
}

View file

@ -255,6 +255,9 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
);
}, [firstTable, isNumericMap, columnConfig]);
const headerRowHeight = props.args.headerRowHeight ?? 'single';
const headerRowLines = props.args.headerRowHeightLines ?? 1;
const columns: EuiDataGridColumn[] = useMemo(
() =>
createGridColumns(
@ -268,7 +271,9 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
formatFactory,
onColumnResize,
onColumnHide,
alignments
alignments,
headerRowHeight,
headerRowLines
),
[
bucketColumns,
@ -282,6 +287,8 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
onColumnResize,
onColumnHide,
alignments,
headerRowHeight,
headerRowLines,
]
);

View file

@ -39,13 +39,21 @@ class Harness {
}
public get rowHeight() {
return this.wrapper.find(EuiButtonGroup);
return this.wrapper.find('[data-test-subj="lnsRowHeightSettings"]').find(EuiButtonGroup);
}
public get headerRowHeight() {
return this.wrapper.find('[data-test-subj="lnsHeaderHeightSettings"]').find(EuiButtonGroup);
}
changeRowHeight(newMode: 'single' | 'auto' | 'custom') {
this.rowHeight.prop('onChange')!(newMode);
}
changeHeaderRowHeight(newMode: 'single' | 'auto' | 'custom') {
this.headerRowHeight.prop('onChange')!(newMode);
}
public get rowHeightLines() {
return this.wrapper.find(EuiRange);
}
@ -83,6 +91,7 @@ describe('datatable toolbar', () => {
frame: {} as FramePublicAPI,
state: {
rowHeight: 'single',
headerRowHeight: 'single',
} as DatatableVisualizationState,
};
@ -93,6 +102,7 @@ describe('datatable toolbar', () => {
harness.togglePopover();
expect(harness.rowHeight.prop('idSelected')).toBe('single');
expect(harness.headerRowHeight.prop('idSelected')).toBe('single');
expect(harness.paginationSwitch.prop('checked')).toBe(false);
harness.wrapper.setProps({
@ -113,16 +123,17 @@ describe('datatable toolbar', () => {
expect(defaultProps.setState).toHaveBeenCalledTimes(1);
expect(defaultProps.setState).toHaveBeenNthCalledWith(1, {
headerRowHeight: 'single',
rowHeight: 'auto',
rowHeightLines: undefined,
});
harness.wrapper.setProps({ state: { rowHeight: 'auto' } }); // update state manually
harness.changeRowHeight('single'); // turn it off
expect(defaultProps.setState).toHaveBeenCalledTimes(2);
expect(defaultProps.setState).toHaveBeenNthCalledWith(2, {
rowHeight: 'single',
headerRowHeight: 'single',
rowHeightLines: 1,
});
});
@ -135,12 +146,22 @@ describe('datatable toolbar', () => {
expect(defaultProps.setState).toHaveBeenCalledTimes(1);
expect(defaultProps.setState).toHaveBeenNthCalledWith(1, {
rowHeight: 'custom',
headerRowHeight: 'single',
rowHeightLines: 2,
});
});
harness.wrapper.setProps({ state: { rowHeight: 'custom' } }); // update state manually
it('should change header height to "Custom" mode', async () => {
harness.togglePopover();
expect(harness.rowHeightLines.prop('value')).toBe(2);
harness.changeHeaderRowHeight('custom');
expect(defaultProps.setState).toHaveBeenCalledTimes(1);
expect(defaultProps.setState).toHaveBeenNthCalledWith(1, {
rowHeight: 'single',
headerRowHeight: 'custom',
headerRowHeightLines: 2,
});
});
it('should toggle table pagination', async () => {
@ -152,17 +173,19 @@ describe('datatable toolbar', () => {
expect(defaultProps.setState).toHaveBeenNthCalledWith(1, {
paging: defaultPagingState,
rowHeight: 'single',
headerRowHeight: 'single',
});
// update state manually
harness.wrapper.setProps({
state: { rowHeight: 'single', paging: defaultPagingState },
state: { rowHeight: 'single', headerRowHeight: 'single', paging: defaultPagingState },
});
harness.togglePagination(); // turn it off. this should disable pagination but preserve the default page size
expect(defaultProps.setState).toHaveBeenCalledTimes(2);
expect(defaultProps.setState).toHaveBeenNthCalledWith(2, {
rowHeight: 'single',
headerRowHeight: 'single',
paging: { ...defaultPagingState, enabled: false },
});
});

View file

@ -7,46 +7,36 @@
import React, { useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonGroup,
EuiFlexGroup,
EuiFormRow,
EuiRange,
EuiSwitch,
EuiToolTip,
htmlIdGenerator,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFormRow, EuiSwitch, EuiToolTip } from '@elastic/eui';
import { ToolbarPopover } from '../../shared_components';
import type { VisualizationToolbarProps } from '../../types';
import type { DatatableVisualizationState } from '../visualization';
import { RowHeightSettings } from './row_height_settings';
import { DEFAULT_PAGE_SIZE } from './table_basic';
const idPrefix = htmlIdGenerator()();
export function DataTableToolbar(props: VisualizationToolbarProps<DatatableVisualizationState>) {
const { state, setState } = props;
const onChangeRowHeight = useCallback(
(newHeightMode) => {
const onChangeHeight = useCallback(
(newHeightMode, heightProperty, heightLinesProperty) => {
const rowHeightLines =
newHeightMode === 'single' ? 1 : newHeightMode !== 'auto' ? 2 : undefined;
setState({
...state,
rowHeight: newHeightMode,
rowHeightLines,
[heightProperty]: newHeightMode,
[heightLinesProperty]: rowHeightLines,
});
},
[setState, state]
);
const onChangeRowHeightLines = useCallback(
(newRowHeightLines) => {
const onChangeHeightLines = useCallback(
(newRowHeightLines, heightLinesProperty) => {
setState({
...state,
rowHeightLines: newRowHeightLines,
[heightLinesProperty]: newRowHeightLines,
});
},
[state, setState]
[setState, state]
);
const onTogglePagination = useCallback(() => {
@ -58,30 +48,6 @@ export function DataTableToolbar(props: VisualizationToolbarProps<DatatableVisua
});
}, [setState, state]);
const rowHeightModeOptions = [
{
id: `${idPrefix}single`,
label: i18n.translate('xpack.lens.table.rowHeight.single', {
defaultMessage: 'Single',
}),
'data-test-subj': 'lnsDatatable_rowHeight_single',
},
{
id: `${idPrefix}auto`,
label: i18n.translate('xpack.lens.table.rowHeight.auto', {
defaultMessage: 'Auto fit',
}),
'data-test-subj': 'lnsDatatable_rowHeight_auto',
},
{
id: `${idPrefix}custom`,
label: i18n.translate('xpack.lens.table.rowHeight.custom', {
defaultMessage: 'Custom',
}),
'data-test-subj': 'lnsDatatable_rowHeight_custom',
},
];
return (
<EuiFlexGroup gutterSize="none" justifyContent="spaceBetween" responsive={false}>
<ToolbarPopover
@ -92,54 +58,33 @@ export function DataTableToolbar(props: VisualizationToolbarProps<DatatableVisua
groupPosition="none"
buttonDataTestSubj="lnsVisualOptionsButton"
>
<EuiFormRow
label={i18n.translate('xpack.lens.table.visualOptionsFitRowToContentLabel', {
defaultMessage: 'Row height',
<RowHeightSettings
rowHeight={state.headerRowHeight}
rowHeightLines={state.headerRowHeightLines}
label={i18n.translate('xpack.lens.table.visualOptionsHeaderRowHeightLabel', {
defaultMessage: 'Header row height',
})}
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
legend={i18n.translate('xpack.lens.table.visualOptionsRowHeight', {
defaultMessage: 'Row height',
})}
data-test-subj="lens-table-row-height"
name="legendLocation"
buttonSize="compressed"
options={rowHeightModeOptions}
idSelected={`${idPrefix}${state.rowHeight ?? 'single'}`}
onChange={(optionId) => {
const newMode = optionId.replace(
idPrefix,
''
) as DatatableVisualizationState['rowHeight'];
onChangeRowHeight(newMode);
}}
/>
</EuiFormRow>
{state.rowHeight === 'custom' ? (
<EuiFormRow
label={i18n.translate('xpack.lens.table.visualOptionsCustomRowHeight', {
defaultMessage: 'Lines per row',
})}
display="columnCompressed"
>
<EuiRange
compressed
fullWidth
showInput
min={1}
max={20}
step={1}
value={state.rowHeightLines ?? 2}
onChange={(e) => {
const lineCount = Number(e.currentTarget.value);
onChangeRowHeightLines(lineCount);
}}
data-test-subj="lens-table-row-height-lineCountNumber"
/>
</EuiFormRow>
) : null}
onChangeRowHeight={(mode) =>
onChangeHeight(mode, 'headerRowHeight', 'headerRowHeightLines')
}
onChangeRowHeightLines={(lines) => {
onChangeHeightLines(lines, 'headerRowHeightLines');
}}
data-test-subj="lnsHeaderHeightSettings"
maxRowHeight={5}
/>
<RowHeightSettings
rowHeight={state.rowHeight}
rowHeightLines={state.rowHeightLines}
label={i18n.translate('xpack.lens.table.visualOptionsFitRowToContentLabel', {
defaultMessage: 'Cell row height',
})}
onChangeRowHeight={(mode) => onChangeHeight(mode, 'rowHeight', 'rowHeightLines')}
onChangeRowHeightLines={(lines) => {
onChangeHeightLines(lines, 'rowHeightLines');
}}
data-test-subj="lnsRowHeightSettings"
/>
<EuiFormRow
label={i18n.translate('xpack.lens.table.visualOptionsPaginateTable', {
defaultMessage: 'Paginate table',

View file

@ -653,6 +653,38 @@ describe('Datatable Visualization', () => {
}).rowHeightLines
).toEqual([2]);
});
it('sets headerRowHeight && headerRowHeightLines correctly', () => {
expect(
getDatatableExpressionArgs({ ...defaultExpressionTableState }).headerRowHeightLines
).toEqual([1]);
// should fallback to single in case it's not set
expect(
getDatatableExpressionArgs({ ...defaultExpressionTableState }).headerRowHeight
).toEqual(['single']);
expect(
getDatatableExpressionArgs({ ...defaultExpressionTableState, headerRowHeight: 'single' })
.headerRowHeightLines
).toEqual([1]);
expect(
getDatatableExpressionArgs({
...defaultExpressionTableState,
headerRowHeight: 'custom',
headerRowHeightLines: 5,
}).headerRowHeightLines
).toEqual([5]);
// should fallback to 2 for custom in case it's not set
expect(
getDatatableExpressionArgs({
...defaultExpressionTableState,
headerRowHeight: 'custom',
}).headerRowHeightLines
).toEqual([2]);
});
});
describe('#getErrorMessages', () => {

View file

@ -33,7 +33,9 @@ export interface DatatableVisualizationState {
layerType: LayerType;
sorting?: SortingState;
rowHeight?: 'auto' | 'single' | 'custom';
headerRowHeight?: 'auto' | 'single' | 'custom';
rowHeightLines?: number;
headerRowHeightLines?: number;
paging?: PagingState;
}
@ -402,9 +404,15 @@ export const getDatatableVisualization = ({
sortingColumnId: [state.sorting?.columnId || ''],
sortingDirection: [state.sorting?.direction || 'none'],
fitRowToContent: [state.rowHeight === 'auto'],
headerRowHeight: [state.headerRowHeight ?? 'single'],
rowHeightLines: [
!state.rowHeight || state.rowHeight === 'single' ? 1 : state.rowHeightLines ?? 2,
],
headerRowHeightLines: [
!state.headerRowHeight || state.headerRowHeight === 'single'
? 1
: state.headerRowHeightLines ?? 2,
],
pageSize: state.paging?.enabled ? [state.paging.size] : [],
},
},