Skip to content

Commit

Permalink
[Lens] New summary row feature for datatable (elastic#101075)
Browse files Browse the repository at this point in the history
* ✨ New summary row feature for datatable

* ✨ Allow empty strings behind flag + tests

* 🐛 Address the transition problem + refactor

* ✅ Add some unit tests

* ✅ Add first functional tests

* 👌 first feedback addressed

* ✨ Make it handle numeric array values

* 📝 Improved message

* ✅ Fix functional test

* 🔥 Remove warning message for last value

* 🚨 Remove unused import

* 🐛 Fix a bug with last value

* 👌 Integrated feedback

* 💄 Migrated to combobox

* ✅ Fix unit tests + restore right data-test-id

* 🏷️ Fix type issue

* 👌 Address all issues reported

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
dej611 and kibanamachine committed Jun 9, 2021
1 parent f9ca6ff commit ac2d686
Show file tree
Hide file tree
Showing 13 changed files with 692 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React from 'react';
import { EuiButtonGroup } from '@elastic/eui';
import { EuiButtonGroup, EuiComboBox, EuiFieldText } from '@elastic/eui';
import { FramePublicAPI, VisualizationDimensionEditorProps } from '../../types';
import { DatatableVisualizationState } from '../visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks';
Expand Down Expand Up @@ -212,4 +212,64 @@ describe('data table dimension editor', () => {

expect(instance.find(PalettePanelContainer).exists()).toBe(true);
});

it('should show the summary field for non numeric columns', () => {
const instance = mountWithIntl(<TableDimensionEditor {...props} />);
expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_function"]').exists()).toBe(
false
);
expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_label"]').exists()).toBe(false);
});

it('should set the summary row function default to "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
const instance = mountWithIntl(<TableDimensionEditor {...props} />);
expect(
instance
.find('[data-test-subj="lnsDatatable_summaryrow_function"]')
.find(EuiComboBox)
.prop('selectedOptions')
).toEqual([{ value: 'none', label: 'None' }]);

expect(instance.find('[data-test-subj="lnsDatatable_summaryrow_label"]').exists()).toBe(false);
});

it('should show the summary row label input ony when summary row is different from "none"', () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].summaryRow = 'sum';
const instance = mountWithIntl(<TableDimensionEditor {...props} />);
expect(
instance
.find('[data-test-subj="lnsDatatable_summaryrow_function"]')
.find(EuiComboBox)
.prop('selectedOptions')
).toEqual([{ value: 'sum', label: 'Sum' }]);

expect(
instance
.find('[data-test-subj="lnsDatatable_summaryrow_label"]')
.find(EuiFieldText)
.prop('value')
).toBe('Sum');
});

it("should show the correct summary row name when user's changes summary label", () => {
frame.activeData!.first.columns[0].meta.type = 'number';
state.columns[0].summaryRow = 'sum';
state.columns[0].summaryLabel = 'MySum';
const instance = mountWithIntl(<TableDimensionEditor {...props} />);
expect(
instance
.find('[data-test-subj="lnsDatatable_summaryrow_function"]')
.find(EuiComboBox)
.prop('selectedOptions')
).toEqual([{ value: 'sum', label: 'Sum' }]);

expect(
instance
.find('[data-test-subj="lnsDatatable_summaryrow_label"]')
.find(EuiFieldText)
.prop('value')
).toBe('MySum');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiFormRow,
Expand All @@ -16,25 +16,35 @@ import {
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
EuiFieldText,
EuiComboBox,
} from '@elastic/eui';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { VisualizationDimensionEditorProps } from '../../types';
import { DatatableVisualizationState } from '../visualization';
import { ColumnState, DatatableVisualizationState } from '../visualization';
import { getOriginalId } from '../transpose_helpers';
import {
CustomizablePalette,
applyPaletteParams,
defaultPaletteParams,
FIXED_PROGRESSION,
getStopsForFixedMode,
useDebouncedValue,
} from '../../shared_components/';
import { PalettePanelContainer } from './palette_panel_container';
import { findMinMaxByColumnId } from './shared_utils';
import './dimension_editor.scss';
import {
getDefaultSummaryLabel,
getFinalSummaryConfiguration,
getSummaryRowOptions,
} from '../summary';
import { isNumericField } from '../utils';

const idPrefix = htmlIdGenerator()();

type ColumnType = DatatableVisualizationState['columns'][number];
type SummaryRowType = Extract<ColumnState['summaryRow'], string>;

function updateColumnWith(
state: DatatableVisualizationState,
Expand All @@ -58,20 +68,41 @@ export function TableDimensionEditor(
const { state, setState, frame, accessor } = props;
const column = state.columns.find(({ columnId }) => accessor === columnId);
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
const onSummaryLabelChangeToDebounce = useCallback(
(newSummaryLabel: string | undefined) => {
setState({
...state,
columns: updateColumnWith(state, accessor, { summaryLabel: newSummaryLabel }),
});
},
[accessor, setState, state]
);
const { inputValue: summaryLabel, handleInputChange: onSummaryLabelChange } = useDebouncedValue<
string | undefined
>(
{
onChange: onSummaryLabelChangeToDebounce,
value: column?.summaryLabel,
},
{ allowEmptyString: true } // empty string is a valid label for this feature
);

if (!column) return null;
if (column.isTransposed) return null;

const currentData = frame.activeData?.[state.layerId];

// either read config state or use same logic as chart itself
const isNumericField =
currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor)
?.meta.type === 'number';

const currentAlignment = column?.alignment || (isNumericField ? 'right' : 'left');
const isNumeric = isNumericField(currentData, accessor);
const currentAlignment = column?.alignment || (isNumeric ? 'right' : 'left');
const currentColorMode = column?.colorMode || 'none';
const hasDynamicColoring = currentColorMode !== 'none';
// when switching from one operation to another, make sure to keep the configuration consistent
const { summaryRow, summaryLabel: fallbackSummaryLabel } = getFinalSummaryConfiguration(
accessor,
column,
currentData
);

const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length;

Expand Down Expand Up @@ -175,7 +206,61 @@ export function TableDimensionEditor(
/>
</EuiFormRow>
)}
{isNumericField && (
{isNumeric && (
<>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.lens.table.summaryRow.label', {
defaultMessage: 'Summary Row',
})}
display="columnCompressed"
>
<EuiComboBox
fullWidth
compressed
isClearable={false}
data-test-subj="lnsDatatable_summaryrow_function"
placeholder={i18n.translate('xpack.lens.indexPattern.fieldPlaceholder', {
defaultMessage: 'Field',
})}
options={getSummaryRowOptions()}
selectedOptions={[
{
label: getDefaultSummaryLabel(summaryRow),
value: summaryRow,
},
]}
singleSelection={{ asPlainText: true }}
onChange={(choices) => {
const newValue = choices[0].value as SummaryRowType;
setState({
...state,
columns: updateColumnWith(state, accessor, { summaryRow: newValue }),
});
}}
/>
</EuiFormRow>
{summaryRow !== 'none' && (
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.table.summaryRow.customlabel', {
defaultMessage: 'Summary label',
})}
>
<EuiFieldText
compressed
data-test-subj="lnsDatatable_summaryrow_label"
value={summaryLabel ?? fallbackSummaryLabel}
onChange={(e) => {
onSummaryLabelChange(e.target.value);
}}
/>
</EuiFormRow>
)}
</>
)}
{isNumeric && (
<>
<EuiFormRow
display="columnCompressed"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,4 +539,106 @@ describe('DatatableComponent', () => {
c: { min: 3, max: 3 },
});
});

test('it does render a summary footer if at least one column has it configured', () => {
const { data, args } = sampleArgs();

const wrapper = mountWithIntl(
<DatatableComponent
data={data}
args={{
...args,
columns: [
...args.columns.slice(0, 2),
{
columnId: 'c',
type: 'lens_datatable_column',
summaryRow: 'sum',
summaryLabel: 'Sum',
summaryRowValue: 3,
},
],
sortingColumnId: 'b',
sortingDirection: 'desc',
}}
formatFactory={() => ({ convert: (x) => x } as IFieldFormat)}
dispatchEvent={onDispatchEvent}
getType={jest.fn()}
renderMode="display"
paletteService={chartPluginMock.createPaletteRegistry()}
uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient}
/>
);
expect(wrapper.find('[data-test-subj="lnsDataTable-footer-a"]').exists()).toEqual(false);
expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').first().text()).toEqual(
'Sum: 3'
);
});

test('it does render a summary footer with just the raw value for empty label', () => {
const { data, args } = sampleArgs();

const wrapper = mountWithIntl(
<DatatableComponent
data={data}
args={{
...args,
columns: [
...args.columns.slice(0, 2),
{
columnId: 'c',
type: 'lens_datatable_column',
summaryRow: 'sum',
summaryLabel: '',
summaryRowValue: 3,
},
],
sortingColumnId: 'b',
sortingDirection: 'desc',
}}
formatFactory={() => ({ convert: (x) => x } as IFieldFormat)}
dispatchEvent={onDispatchEvent}
getType={jest.fn()}
renderMode="display"
paletteService={chartPluginMock.createPaletteRegistry()}
uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient}
/>
);

expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').first().text()).toEqual('3');
});

test('it does not render the summary row if the only column with summary is hidden', () => {
const { data, args } = sampleArgs();

const wrapper = mountWithIntl(
<DatatableComponent
data={data}
args={{
...args,
columns: [
...args.columns.slice(0, 2),
{
columnId: 'c',
type: 'lens_datatable_column',
summaryRow: 'sum',
summaryLabel: '',
summaryRowValue: 3,
hidden: true,
},
],
sortingColumnId: 'b',
sortingDirection: 'desc',
}}
formatFactory={() => ({ convert: (x) => x } as IFieldFormat)}
dispatchEvent={onDispatchEvent}
getType={jest.fn()}
renderMode="display"
paletteService={chartPluginMock.createPaletteRegistry()}
uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient}
/>
);

expect(wrapper.find('[data-test-subj="lnsDataTable-footer-c"]').exists()).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from './table_actions';
import { findMinMaxByColumnId } from './shared_utils';
import { CUSTOM_PALETTE } from '../../shared_components/coloring/constants';
import { getFinalSummaryConfiguration } from '../summary';

export const DataContext = React.createContext<DataContextType>({});

Expand Down Expand Up @@ -286,6 +287,40 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
[onEditAction, sortBy, sortDirection]
);

const renderSummaryRow = useMemo(() => {
const columnsWithSummary = columnConfig.columns
.filter((col) => !!col.columnId && !col.hidden)
.map((config) => ({
columnId: config.columnId,
summaryRowValue: config.summaryRowValue,
...getFinalSummaryConfiguration(config.columnId, config, firstTable),
}))
.filter(({ summaryRow }) => summaryRow !== 'none');

if (columnsWithSummary.length) {
const summaryLookup = Object.fromEntries(
columnsWithSummary.map(({ summaryRowValue, summaryLabel, columnId }) => [
columnId,
summaryLabel === '' ? `${summaryRowValue}` : `${summaryLabel}: ${summaryRowValue}`,
])
);
return ({ columnId }: { columnId: string }) => {
const currentAlignment = alignments && alignments[columnId];
const alignmentClassName = `lnsTableCell--${currentAlignment}`;
const columnName =
columns.find(({ id }) => id === columnId)?.displayAsText?.replace(/ /g, '-') || columnId;
return summaryLookup[columnId] != null ? (
<div
className={`lnsTableCell ${alignmentClassName}`}
data-test-subj={`lnsDataTable-footer-${columnName}`}
>
{summaryLookup[columnId]}
</div>
) : null;
};
}
}, [columnConfig.columns, alignments, firstTable, columns]);

if (isEmpty) {
return <EmptyPlaceholder icon={LensIconChartDatatable} />;
}
Expand Down Expand Up @@ -323,6 +358,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
sorting={sorting}
onColumnResize={onColumnResize}
toolbarVisibility={false}
renderFooterCellValue={renderSummaryRow}
/>
</DataContext.Provider>
</VisualizationContainer>
Expand Down
Loading

0 comments on commit ac2d686

Please sign in to comment.