[Lens] Allow visualizations to provide a dimension editor (#67560)

* [Lens] Allow visualizations to provide a dimension editor

* Update to tab style

* Remove table update

* Update class name

* typecheck fix

* Add test

* Require each dimension group to enable editor

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
This commit is contained in:
Wylie Conlon 2020-06-01 13:33:23 -04:00 committed by GitHub
parent ce47ef5d24
commit be51ca6041
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 458 additions and 106 deletions

View file

@ -31,3 +31,6 @@
min-height: $euiSizeXXL;
}
.lnsLayerPanel__styleEditor {
width: $euiSize * 28;
}

View file

@ -45,7 +45,6 @@ function LayerPanels(
}
) {
const {
framePublicAPI,
activeVisualization,
visualizationState,
dispatch,
@ -109,12 +108,10 @@ function LayerPanels(
{...props}
key={layerId}
layerId={layerId}
activeVisualization={activeVisualization}
visualizationState={visualizationState}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateAll={updateAll}
frame={framePublicAPI}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
dispatch({

View file

@ -36,7 +36,7 @@ export function DimensionPopover({
(popoverState.openId === accessor || (noMatch && popoverState.addingToGroupId === groupId))
}
closePopover={() => {
setPopoverState({ isOpen: false, openId: null, addingToGroupId: null });
setPopoverState({ isOpen: false, openId: null, addingToGroupId: null, tabId: null });
}}
button={trigger}
anchorPosition="leftUp"

View file

@ -0,0 +1,271 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
createMockVisualization,
createMockFramePublicAPI,
createMockDatasource,
DatasourceMock,
} from '../../mocks';
import { EuiFormRow, EuiPopover } from '@elastic/eui';
import { mount } from 'enzyme';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { Visualization } from '../../../types';
import { LayerPanel } from './layer_panel';
import { coreMock } from 'src/core/public/mocks';
import { generateId } from '../../../id_generator';
jest.mock('../../../id_generator');
describe('LayerPanel', () => {
let mockVisualization: jest.Mocked<Visualization>;
let mockDatasource: DatasourceMock;
function getDefaultProps() {
const frame = createMockFramePublicAPI();
frame.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
return {
layerId: 'first',
activeVisualizationId: 'vis1',
visualizationMap: {
vis1: mockVisualization,
},
activeDatasourceId: 'ds1',
datasourceMap: {
ds1: mockDatasource,
},
datasourceStates: {
ds1: {
isLoading: false,
state: 'state',
},
},
visualizationState: 'state',
updateVisualization: jest.fn(),
updateDatasource: jest.fn(),
updateAll: jest.fn(),
framePublicAPI: frame,
isOnlyLayer: true,
onRemoveLayer: jest.fn(),
dispatch: jest.fn(),
core: coreMock.createStart(),
};
}
beforeEach(() => {
mockVisualization = {
...createMockVisualization(),
id: 'testVis',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis',
label: 'TEST1',
},
],
};
mockVisualization.getLayerIds.mockReturnValue(['first']);
mockDatasource = createMockDatasource('ds1');
});
it('should fail to render if the public API is out of date', () => {
const props = getDefaultProps();
props.framePublicAPI.datasourceLayers = {};
const component = mountWithIntl(<LayerPanel {...props} />);
expect(component.isEmptyRender()).toBe(true);
});
it('should fail to render if the active visualization is missing', () => {
const component = mountWithIntl(
<LayerPanel {...getDefaultProps()} activeVisualizationId="missing" />
);
expect(component.isEmptyRender()).toBe(true);
});
describe('layer reset and remove', () => {
it('should show the reset button when single layer', () => {
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
expect(component.find('[data-test-subj="lns_layer_remove"]').first().text()).toContain(
'Reset layer'
);
});
it('should show the delete button when multiple layers', () => {
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} isOnlyLayer={false} />);
expect(component.find('[data-test-subj="lns_layer_remove"]').first().text()).toContain(
'Delete layer'
);
});
it('should call the clear callback', () => {
const cb = jest.fn();
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} onRemoveLayer={cb} />);
act(() => {
component.find('[data-test-subj="lns_layer_remove"]').first().simulate('click');
});
expect(cb).toHaveBeenCalled();
});
});
describe('single group', () => {
it('should render the non-editable state', () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: ['x'],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DragDrop[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('should render the group with a way to add a new column', () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DragDrop[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('should render the required warning when only one group is configured', () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: ['x'],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
},
{
groupLabel: 'B',
groupId: 'b',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
required: true,
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component
.find(EuiFormRow)
.findWhere((e) => e.prop('error') === 'Required dimension');
expect(group).toHaveLength(1);
});
it('should render the datasource and visualization panels inside the dimension popover', () => {
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: ['newid'],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
enableDimensionEditor: true,
},
],
});
mockVisualization.renderDimensionEditor = jest.fn();
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DimensionPopover');
const panel = mount(group.prop('panel'));
expect(panel.find('EuiTabbedContent').prop('tabs')).toHaveLength(2);
act(() => {
panel.find('EuiTab#visualization').simulate('click');
});
expect(mockVisualization.renderDimensionEditor).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
groupId: 'a',
accessor: 'newid',
})
);
});
it('should keep the popover open when configuring a new dimension', () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the popover
* open in future renders
*/
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
(generateId as jest.Mock).mockReturnValueOnce(`bad`);
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
// Normally the configuration would change in response to a state update,
// but this test is updating it directly
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: ['newid'],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DimensionPopover');
const triggerButton = mountWithIntl(group.prop('trigger'));
act(() => {
triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
expect(component.find(EuiPopover).prop('isOpen')).toBe(true);
});
});
});

View file

@ -13,11 +13,12 @@ import {
EuiFlexItem,
EuiButtonEmpty,
EuiFormRow,
EuiTabbedContent,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { NativeRenderer } from '../../../native_renderer';
import { Visualization, FramePublicAPI, StateSetter } from '../../../types';
import { StateSetter } from '../../../types';
import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
import { trackUiEvent } from '../../../lens_ui_telemetry';
@ -27,11 +28,8 @@ import { DimensionPopover } from './dimension_popover';
export function LayerPanel(
props: Exclude<ConfigPanelWrapperProps, 'state' | 'setState'> & {
frame: FramePublicAPI;
layerId: string;
isOnlyLayer: boolean;
activeVisualization: Visualization;
visualizationState: unknown;
updateVisualization: StateSetter<unknown>;
updateDatasource: (datasourceId: string, newState: unknown) => void;
updateAll: (
@ -47,13 +45,19 @@ export function LayerPanel(
isOpen: false,
openId: null,
addingToGroupId: null,
tabId: null,
});
const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props;
const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer } = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
if (!datasourcePublicAPI) {
if (
!datasourcePublicAPI ||
!props.activeVisualizationId ||
!props.visualizationMap[props.activeVisualizationId]
) {
return null;
}
const activeVisualization = props.visualizationMap[props.activeVisualizationId];
const layerVisualizationConfigProps = {
layerId,
dragDropContext,
@ -158,104 +162,156 @@ export function LayerPanel(
}
>
<>
{group.accessors.map((accessor) => (
<DragDrop
key={accessor}
className="lnsLayerPanel__dimension"
data-test-subj={group.dataTestSubj}
droppable={
dragDropContext.dragging &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: accessor,
filterOperations: group.filterOperations,
})
}
onDrop={(droppedItem) => {
layerDatasource.onDrop({
...layerDatasourceDropProps,
droppedItem,
columnId: accessor,
filterOperations: group.filterOperations,
});
}}
>
<DimensionPopover
popoverState={popoverState}
setPopoverState={setPopoverState}
groups={groups}
accessor={accessor}
groupId={group.groupId}
trigger={
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessor,
filterOperations: group.filterOperations,
suggestedPriority: group.suggestedPriority,
togglePopover: () => {
if (popoverState.isOpen) {
setPopoverState({
isOpen: false,
openId: null,
addingToGroupId: null,
});
} else {
setPopoverState({
isOpen: true,
openId: accessor,
addingToGroupId: null, // not set for existing dimension
});
}
},
}}
/>
}
panel={
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionEditor}
nativeProps={{
...layerDatasourceConfigProps,
core: props.core,
columnId: accessor,
filterOperations: group.filterOperations,
}}
/>
}
/>
{group.accessors.map((accessor) => {
const tabs = [
{
id: 'datasource',
name: i18n.translate('xpack.lens.editorFrame.quickFunctionsLabel', {
defaultMessage: 'Quick functions',
}),
content: (
<>
<EuiSpacer size="s" />
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionEditor}
nativeProps={{
...layerDatasourceConfigProps,
core: props.core,
columnId: accessor,
filterOperations: group.filterOperations,
}}
/>
</>
),
},
];
<EuiButtonIcon
data-test-subj="indexPattern-dimensionPopover-remove"
iconType="cross"
iconSize="s"
size="s"
color="danger"
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration',
})}
title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration',
})}
onClick={() => {
trackUiEvent('indexpattern_dimension_removed');
props.updateAll(
datasourceId,
layerDatasource.removeColumn({
layerId,
columnId: accessor,
prevState: layerDatasourceState,
}),
props.activeVisualization.removeDimension({
layerId,
columnId: accessor,
prevState: props.visualizationState,
})
);
if (activeVisualization.renderDimensionEditor) {
tabs.push({
id: 'visualization',
name: i18n.translate('xpack.lens.editorFrame.formatStyleLabel', {
defaultMessage: 'Format & style',
}),
content: (
<div className="lnsLayerPanel__styleEditor">
<EuiSpacer size="s" />
<NativeRenderer
render={activeVisualization.renderDimensionEditor}
nativeProps={{
...layerVisualizationConfigProps,
groupId: group.groupId,
accessor,
setState: props.updateVisualization,
}}
/>
</div>
),
});
}
return (
<DragDrop
key={accessor}
className="lnsLayerPanel__dimension"
data-test-subj={group.dataTestSubj}
droppable={
dragDropContext.dragging &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: accessor,
filterOperations: group.filterOperations,
})
}
onDrop={(droppedItem) => {
layerDatasource.onDrop({
...layerDatasourceDropProps,
droppedItem,
columnId: accessor,
filterOperations: group.filterOperations,
});
}}
/>
</DragDrop>
))}
>
<DimensionPopover
popoverState={popoverState}
setPopoverState={setPopoverState}
groups={groups}
accessor={accessor}
groupId={group.groupId}
trigger={
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessor,
filterOperations: group.filterOperations,
suggestedPriority: group.suggestedPriority,
togglePopover: () => {
if (popoverState.isOpen) {
setPopoverState({
isOpen: false,
openId: null,
addingToGroupId: null,
tabId: null,
});
} else {
setPopoverState({
isOpen: true,
openId: accessor,
addingToGroupId: null, // not set for existing dimension
tabId: 'datasource',
});
}
},
}}
/>
}
panel={
<EuiTabbedContent
tabs={tabs}
initialSelectedTab={tabs.find((t) => t.id === popoverState.tabId)}
size="s"
onTabClick={(tab) => {
setPopoverState({
...popoverState,
tabId: tab.id as typeof popoverState['tabId'],
});
}}
/>
}
/>
<EuiButtonIcon
data-test-subj="indexPattern-dimensionPopover-remove"
iconType="cross"
iconSize="s"
size="s"
color="danger"
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration',
})}
title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration',
})}
onClick={() => {
trackUiEvent('indexpattern_dimension_removed');
props.updateAll(
datasourceId,
layerDatasource.removeColumn({
layerId,
columnId: accessor,
prevState: layerDatasourceState,
}),
activeVisualization.removeDimension({
layerId,
columnId: accessor,
prevState: props.visualizationState,
})
);
}}
/>
</DragDrop>
);
})}
{group.supportsMoreColumns ? (
<DragDrop
className="lnsLayerPanel__dimension"
@ -310,12 +366,14 @@ export function LayerPanel(
isOpen: false,
openId: null,
addingToGroupId: null,
tabId: null,
});
} else {
setPopoverState({
isOpen: true,
openId: newId,
addingToGroupId: group.groupId,
tabId: 'datasource',
});
}
}}
@ -353,6 +411,7 @@ export function LayerPanel(
isOpen: true,
openId: newId,
addingToGroupId: null, // clear now that dimension exists
tabId: popoverState.tabId ?? 'datasource',
});
},
}}

View file

@ -34,4 +34,5 @@ export interface DimensionPopoverState {
isOpen: boolean;
openId: string | null;
addingToGroupId: string | null;
tabId: 'datasource' | 'visualization' | null;
}

View file

@ -290,6 +290,12 @@ export type VisualizationLayerWidgetProps<T = unknown> = VisualizationConfigProp
setState: (newState: T) => void;
};
export type VisualizationDimensionEditorProps<T = unknown> = VisualizationConfigProps<T> & {
groupId: string;
accessor: string;
setState: (newState: T) => void;
};
export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
groupLabel: string;
@ -300,6 +306,12 @@ export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
/** If required, a warning will appear if accessors are empty */
required?: boolean;
dataTestSubj?: string;
/**
* When the dimension editor is enabled for this group, all dimensions in the group
* will render the extra tab for the dimension editor
*/
enableDimensionEditor?: boolean;
};
interface VisualizationDimensionChangeProps<T> {
@ -459,6 +471,15 @@ export interface Visualization<T = unknown, P = unknown> {
*/
removeDimension: (props: VisualizationDimensionChangeProps<T>) => T;
/**
* Additional editor that gets rendered inside the dimension popover.
* This can be used to configure dimension-specific options
*/
renderDimensionEditor?: (
domElement: Element,
props: VisualizationDimensionEditorProps<T>
) => void;
/**
* The frame will call this function on all visualizations at different times. The
* main use cases where visualization suggestions are requested are: