Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7.x] [Lens] New summary row feature for datatable (#101075) #101740

Merged
merged 1 commit into from
Jun 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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