[Lens] Add clear layer feature (#53627) (#54764)

* [Lens] Add clear layer feature

* Move clear / remove layer out of the context menu

* Address code review comments

* Remove xpack.lens.xyChart.deleteLayer translation

* Get rid of unused Lens translations

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Chris Davies <github@christophilus.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Wylie Conlon 2020-01-14 13:32:58 -05:00 committed by GitHub
parent 69c8aa4595
commit f1afb6f826
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1019 additions and 640 deletions

View file

@ -72,6 +72,42 @@ describe('Datatable Visualization', () => {
});
});
describe('#getLayerIds', () => {
it('return the layer ids', () => {
const state: DatatableVisualizationState = {
layers: [
{
layerId: 'baz',
columns: ['a', 'b', 'c'],
},
],
};
expect(datatableVisualization.getLayerIds(state)).toEqual(['baz']);
});
});
describe('#clearLayer', () => {
it('should reset the layer', () => {
(generateId as jest.Mock).mockReturnValueOnce('testid');
const state: DatatableVisualizationState = {
layers: [
{
layerId: 'baz',
columns: ['a', 'b', 'c'],
},
],
};
expect(datatableVisualization.clearLayer(state, 'baz')).toMatchObject({
layers: [
{
layerId: 'baz',
columns: ['testid'],
},
],
});
});
});
describe('#getSuggestions', () => {
function numCol(columnId: string): TableSuggestionColumn {
return {
@ -188,6 +224,7 @@ describe('Datatable Visualization', () => {
mount(
<DataTableLayer
layerId="layer1"
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
frame={frame}
layer={layer}
@ -224,6 +261,7 @@ describe('Datatable Visualization', () => {
frame.datasourceLayers = { a: datasource.publicAPIMock };
const component = mount(
<DataTableLayer
layerId="layer1"
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
frame={frame}
layer={layer}
@ -258,6 +296,7 @@ describe('Datatable Visualization', () => {
frame.datasourceLayers = { a: datasource.publicAPIMock };
const component = mount(
<DataTableLayer
layerId="layer1"
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
frame={frame}
layer={layer}
@ -290,6 +329,7 @@ describe('Datatable Visualization', () => {
frame.datasourceLayers = { a: datasource.publicAPIMock };
const component = mount(
<DataTableLayer
layerId="layer1"
dragDropContext={{ dragging: undefined, setDragging: () => {} }}
frame={frame}
layer={layer}

View file

@ -6,19 +6,18 @@
import React from 'react';
import { render } from 'react-dom';
import { EuiForm, EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n/react';
import { MultiColumnEditor } from '../multi_column_editor';
import {
SuggestionRequest,
Visualization,
VisualizationProps,
VisualizationLayerConfigProps,
VisualizationSuggestion,
Operation,
} from '../types';
import { generateId } from '../id_generator';
import { NativeRenderer } from '../native_renderer';
import chartTableSVG from '../assets/chart_datatable.svg';
export interface LayerState {
@ -56,7 +55,7 @@ export function DataTableLayer({
state,
setState,
dragDropContext,
}: { layer: LayerState } & VisualizationProps<DatatableVisualizationState>) {
}: { layer: LayerState } & VisualizationLayerConfigProps<DatatableVisualizationState>) {
const datasource = frame.datasourceLayers[layer.layerId];
const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId);
@ -64,32 +63,24 @@ export function DataTableLayer({
const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns)));
return (
<EuiPanel className="lnsConfigPanel__panel" paddingSize="s">
<NativeRenderer
render={datasource.renderLayerPanel}
nativeProps={{ layerId: layer.layerId }}
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.datatable.columns', { defaultMessage: 'Columns' })}
>
<MultiColumnEditor
accessors={sortedColumns}
datasource={datasource}
dragDropContext={dragDropContext}
filterOperations={allOperations}
layerId={layer.layerId}
onAdd={() => setState(updateColumns(state, layer, columns => [...columns, generateId()]))}
onRemove={column =>
setState(updateColumns(state, layer, columns => columns.filter(c => c !== column)))
}
testSubj="datatable_columns"
data-test-subj="datatable_multicolumnEditor"
/>
<EuiSpacer size="s" />
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.datatable.columns', { defaultMessage: 'Columns' })}
>
<MultiColumnEditor
accessors={sortedColumns}
datasource={datasource}
dragDropContext={dragDropContext}
filterOperations={allOperations}
layerId={layer.layerId}
onAdd={() => setState(updateColumns(state, layer, columns => [...columns, generateId()]))}
onRemove={column =>
setState(updateColumns(state, layer, columns => columns.filter(c => c !== column)))
}
testSubj="datatable_columns"
data-test-subj="datatable_multicolumnEditor"
/>
</EuiFormRow>
</EuiPanel>
</EuiFormRow>
);
}
@ -110,7 +101,17 @@ export const datatableVisualization: Visualization<
},
],
getDescription(state) {
getLayerIds(state) {
return state.layers.map(l => l.layerId);
},
clearLayer(state) {
return {
layers: state.layers.map(l => newLayerState(l.layerId)),
};
},
getDescription() {
return {
icon: chartTableSVG,
label: i18n.translate('xpack.lens.datatable.label', {
@ -187,17 +188,18 @@ export const datatableVisualization: Visualization<
];
},
renderConfigPanel: (domElement, props) =>
render(
<I18nProvider>
<EuiForm className="lnsConfigPanel">
{props.state.layers.map(layer => (
<DataTableLayer key={layer.layerId} layer={layer} {...props} />
))}
</EuiForm>
</I18nProvider>,
domElement
),
renderLayerConfigPanel(domElement, props) {
const layer = props.state.layers.find(l => l.layerId === props.layerId);
if (layer) {
render(
<I18nProvider>
<DataTableLayer {...props} layer={layer} />
</I18nProvider>,
domElement
);
}
},
toExpression(state, frame) {
const layer = state.layers[0];

View file

@ -81,7 +81,6 @@ export function ChartSwitch(props: Props) {
trackUiEvent(`chart_switch`);
switchToSuggestion(
props.framePublicAPI,
props.dispatch,
{
...selection,

View file

@ -4,14 +4,36 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useContext, memo } from 'react';
import React, { useMemo, useContext, memo, useState } from 'react';
import {
EuiPanel,
EuiSpacer,
EuiPopover,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiToolTip,
EuiButton,
EuiForm,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { NativeRenderer } from '../../native_renderer';
import { Action } from './state_management';
import { Visualization, FramePublicAPI, Datasource } from '../../types';
import {
Visualization,
FramePublicAPI,
Datasource,
VisualizationLayerConfigProps,
} from '../../types';
import { DragContext } from '../../drag_drop';
import { ChartSwitch } from './chart_switch';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { generateId } from '../../id_generator';
import { removeLayer, appendLayer } from './layer_actions';
interface ConfigPanelWrapperProps {
activeDatasourceId: string;
visualizationState: unknown;
visualizationMap: Record<string, Visualization>;
activeVisualizationId: string | null;
@ -28,17 +50,8 @@ interface ConfigPanelWrapperProps {
}
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const context = useContext(DragContext);
const setVisualizationState = useMemo(
() => (newState: unknown) => {
props.dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
newState,
clearStagedPreview: false,
});
},
[props.dispatch]
);
const activeVisualization = props.visualizationMap[props.activeVisualizationId || ''];
const { visualizationState } = props;
return (
<>
@ -52,19 +65,235 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config
dispatch={props.dispatch}
framePublicAPI={props.framePublicAPI}
/>
{props.activeVisualizationId && props.visualizationState !== null && (
<div className="lnsConfigPanelWrapper">
<NativeRenderer
render={props.visualizationMap[props.activeVisualizationId].renderConfigPanel}
nativeProps={{
dragDropContext: context,
state: props.visualizationState,
setState: setVisualizationState,
frame: props.framePublicAPI,
}}
/>
</div>
{activeVisualization && visualizationState && (
<LayerPanels {...props} activeVisualization={activeVisualization} />
)}
</>
);
});
function LayerPanels(
props: ConfigPanelWrapperProps & {
activeDatasourceId: string;
activeVisualization: Visualization;
}
) {
const {
framePublicAPI,
activeVisualization,
visualizationState,
dispatch,
activeDatasourceId,
datasourceMap,
} = props;
const dragDropContext = useContext(DragContext);
const setState = useMemo(
() => (newState: unknown) => {
props.dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState,
clearStagedPreview: false,
});
},
[props.dispatch, activeVisualization]
);
const layerIds = activeVisualization.getLayerIds(visualizationState);
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map(layerId => (
<LayerPanel
{...props}
key={layerId}
layerId={layerId}
activeVisualization={activeVisualization}
dragDropContext={dragDropContext}
state={setState}
setState={setState}
frame={framePublicAPI}
isOnlyLayer={layerIds.length === 1}
onRemove={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: state =>
removeLayer({
activeVisualization,
layerId,
trackUiEvent,
datasourceMap,
state,
}),
});
}}
/>
))}
{activeVisualization.appendLayer && (
<EuiFlexItem grow={true}>
<EuiToolTip
className="eui-fullWidth"
content={i18n.translate('xpack.lens.xyChart.addLayerTooltip', {
defaultMessage:
'Use multiple layers to combine chart types or visualize different index patterns.',
})}
position="bottom"
>
<EuiButton
className="lnsConfigPanel__addLayerBtn"
fullWidth
size="s"
data-test-subj={`lnsXY_layer_add`}
aria-label={i18n.translate('xpack.lens.xyChart.addLayerButton', {
defaultMessage: 'Add layer',
})}
title={i18n.translate('xpack.lens.xyChart.addLayerButton', {
defaultMessage: 'Add layer',
})}
onClick={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'ADD_LAYER',
updater: state =>
appendLayer({
activeVisualization,
generateId,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId],
state,
}),
});
}}
iconType="plusInCircleFilled"
/>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiForm>
);
}
function LayerPanel(
props: ConfigPanelWrapperProps &
VisualizationLayerConfigProps<unknown> & {
isOnlyLayer: boolean;
activeVisualization: Visualization;
onRemove: () => void;
}
) {
const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemove } = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
const layerConfigProps = {
layerId,
dragDropContext: props.dragDropContext,
state: props.visualizationState,
setState: props.setState,
frame: props.framePublicAPI,
};
return (
<EuiPanel className="lnsConfigPanel__panel" paddingSize="s">
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<LayerSettings
layerId={layerId}
layerConfigProps={layerConfigProps}
activeVisualization={activeVisualization}
/>
</EuiFlexItem>
{datasourcePublicAPI && (
<EuiFlexItem className="eui-textTruncate">
<NativeRenderer
render={datasourcePublicAPI.renderLayerPanel}
nativeProps={{ layerId }}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="s" />
<NativeRenderer
render={activeVisualization.renderLayerConfigPanel}
nativeProps={layerConfigProps}
/>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="trash"
color="danger"
data-test-subj="lns_layer_remove"
onClick={() => {
// If we don't blur the remove / clear button, it remains focused
// which is a strange UX in this case. e.target.blur doesn't work
// due to who knows what, but probably event re-writing. Additionally,
// activeElement does not have blur so, we need to do some casting + safeguards.
const el = (document.activeElement as unknown) as { blur: () => void };
if (el && el.blur) {
el.blur();
}
onRemove();
}}
>
{isOnlyLayer
? i18n.translate('xpack.lens.resetLayer', {
defaultMessage: 'Reset layer',
})
: i18n.translate('xpack.lens.deleteLayer', {
defaultMessage: 'Delete layer',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}
function LayerSettings({
layerId,
activeVisualization,
layerConfigProps,
}: {
layerId: string;
activeVisualization: Visualization;
layerConfigProps: VisualizationLayerConfigProps;
}) {
const [isOpen, setIsOpen] = useState(false);
if (!activeVisualization.renderLayerContextMenu) {
return null;
}
return (
<EuiPopover
id={`lnsLayerPopover_${layerId}`}
panelPaddingSize="s"
ownFocus
button={
<EuiButtonIcon
iconType={activeVisualization.getLayerContextMenuIcon?.(layerConfigProps) || 'gear'}
aria-label={i18n.translate('xpack.lens.editLayerSettings', {
defaultMessage: 'Edit layer settings',
})}
onClick={() => setIsOpen(!isOpen)}
data-test-subj="lns_layer_settings"
/>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
anchorPosition="leftUp"
>
<NativeRenderer
render={activeVisualization.renderLayerContextMenu}
nativeProps={layerConfigProps}
/>
</EuiPopover>
);
}

View file

@ -9,7 +9,7 @@ import { ReactWrapper } from 'enzyme';
import { EuiPanel, EuiToolTip } from '@elastic/eui';
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
import { EditorFrame } from './editor_frame';
import { Visualization, DatasourcePublicAPI, DatasourceSuggestion } from '../../types';
import { DatasourcePublicAPI, DatasourceSuggestion, Visualization } from '../../types';
import { act } from 'react-dom/test-utils';
import { coreMock } from 'src/core/public/mocks';
import {
@ -24,7 +24,11 @@ import { FrameLayout } from './frame_layout';
// calling this function will wait for all pending Promises from mock
// datasources to be processed by its callers.
const waitForPromises = () => new Promise(resolve => setTimeout(resolve));
async function waitForPromises(n = 3) {
for (let i = 0; i < n; ++i) {
await Promise.resolve();
}
}
function generateSuggestion(state = {}): DatasourceSuggestion {
return {
@ -88,6 +92,9 @@ describe('editor_frame', () => {
],
};
mockVisualization.getLayerIds.mockReturnValue(['first']);
mockVisualization2.getLayerIds.mockReturnValue(['second']);
mockDatasource = createMockDatasource();
mockDatasource2 = createMockDatasource();
@ -202,7 +209,7 @@ describe('editor_frame', () => {
);
});
expect(mockVisualization.renderConfigPanel).not.toHaveBeenCalled();
expect(mockVisualization.renderLayerConfigPanel).not.toHaveBeenCalled();
expect(mockDatasource.renderDataPanel).not.toHaveBeenCalled();
});
@ -294,6 +301,7 @@ describe('editor_frame', () => {
it('should remove layer on active datasource on frame api call', async () => {
const initialState = { datasource2: '' };
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource2.initialize.mockReturnValue(Promise.resolve(initialState));
mockDatasource2.getLayers.mockReturnValue(['abc', 'def']);
mockDatasource2.removeLayer.mockReturnValue({ removed: true });
@ -361,7 +369,7 @@ describe('editor_frame', () => {
it('should initialize visualization state and render config panel', async () => {
const initialState = {};
mockDatasource.getLayers.mockReturnValue(['first']);
mount(
<EditorFrame
{...getDefaultProps()}
@ -382,7 +390,7 @@ describe('editor_frame', () => {
await waitForPromises();
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({ state: initialState })
);
@ -390,6 +398,7 @@ describe('editor_frame', () => {
it('should render the resulting expression using the expression renderer', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const instance = mount(
<EditorFrame
{...getDefaultProps()}
@ -508,7 +517,6 @@ describe('editor_frame', () => {
/>
);
await waitForPromises();
await waitForPromises();
instance.update();
@ -601,6 +609,7 @@ describe('editor_frame', () => {
describe('state update', () => {
it('should re-render config panel after state update', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mount(
<EditorFrame
{...getDefaultProps()}
@ -619,14 +628,14 @@ describe('editor_frame', () => {
await waitForPromises();
const updatedState = {};
const setVisualizationState = (mockVisualization.renderConfigPanel as jest.Mock).mock
const setVisualizationState = (mockVisualization.renderLayerConfigPanel as jest.Mock).mock
.calls[0][1].setState;
act(() => {
setVisualizationState(updatedState);
});
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2);
expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith(
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2);
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith(
expect.any(Element),
expect.objectContaining({
state: updatedState,
@ -635,6 +644,7 @@ describe('editor_frame', () => {
});
it('should re-render data panel after state update', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mount(
<EditorFrame
{...getDefaultProps()}
@ -689,10 +699,13 @@ describe('editor_frame', () => {
await waitForPromises();
const updatedPublicAPI = {};
mockDatasource.getPublicAPI.mockReturnValue(
(updatedPublicAPI as unknown) as DatasourcePublicAPI
);
const updatedPublicAPI: DatasourcePublicAPI = {
renderLayerPanel: jest.fn(),
renderDimensionPanel: jest.fn(),
getOperationForColumnId: jest.fn(),
getTableSpec: jest.fn(),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
const setDatasourceState = (mockDatasource.renderDataPanel as jest.Mock).mock.calls[0][1]
.setState;
@ -700,8 +713,8 @@ describe('editor_frame', () => {
setDatasourceState({});
});
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(2);
expect(mockVisualization.renderConfigPanel).toHaveBeenLastCalledWith(
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(2);
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenLastCalledWith(
expect.any(Element),
expect.objectContaining({
frame: expect.objectContaining({
@ -754,10 +767,10 @@ describe('editor_frame', () => {
await waitForPromises();
expect(mockVisualization.renderConfigPanel).toHaveBeenCalled();
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalled();
const datasourceLayers =
mockVisualization.renderConfigPanel.mock.calls[0][1].frame.datasourceLayers;
mockVisualization.renderLayerConfigPanel.mock.calls[0][1].frame.datasourceLayers;
expect(datasourceLayers.first).toBe(mockDatasource.publicAPIMock);
expect(datasourceLayers.second).toBe(mockDatasource2.publicAPIMock);
expect(datasourceLayers.third).toBe(mockDatasource2.publicAPIMock);
@ -919,7 +932,7 @@ describe('editor_frame', () => {
}
beforeEach(async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
mockDatasource.getLayers.mockReturnValue(['first', 'second']);
mockDatasource.getDatasourceSuggestionsFromCurrentState.mockReturnValue([
{
state: {},
@ -1018,7 +1031,7 @@ describe('editor_frame', () => {
expect(mockVisualization2.getSuggestions).toHaveBeenCalled();
expect(mockVisualization2.initialize).toHaveBeenCalledWith(expect.anything(), initialState);
expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({ state: { initial: true } })
);
@ -1032,9 +1045,11 @@ describe('editor_frame', () => {
expect(mockDatasource.publicAPIMock.getTableSpec).toHaveBeenCalled();
expect(mockVisualization2.getSuggestions).toHaveBeenCalled();
expect(mockVisualization2.initialize).toHaveBeenCalledWith(
expect.objectContaining({ datasourceLayers: { first: mockDatasource.publicAPIMock } })
expect.objectContaining({
datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }),
})
);
expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({ state: { initial: true } })
);
@ -1102,6 +1117,7 @@ describe('editor_frame', () => {
});
it('should display top 5 suggestions in descending order', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const instance = mount(
<EditorFrame
{...getDefaultProps()}
@ -1185,6 +1201,7 @@ describe('editor_frame', () => {
});
it('should switch to suggested visualization', async () => {
mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']);
const newDatasourceState = {};
const suggestionVisState = {};
const instance = mount(
@ -1228,8 +1245,8 @@ describe('editor_frame', () => {
.simulate('click');
});
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledTimes(1);
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledTimes(1);
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
state: suggestionVisState,
@ -1244,6 +1261,7 @@ describe('editor_frame', () => {
});
it('should switch to best suggested visualization on field drop', async () => {
mockDatasource.getLayers.mockReturnValue(['first']);
const suggestionVisState = {};
const instance = mount(
<EditorFrame
@ -1293,7 +1311,7 @@ describe('editor_frame', () => {
.simulate('drop');
});
expect(mockVisualization.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
state: suggestionVisState,
@ -1302,6 +1320,7 @@ describe('editor_frame', () => {
});
it('should use the currently selected visualization if possible on field drop', async () => {
mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']);
const suggestionVisState = {};
const instance = mount(
<EditorFrame
@ -1366,7 +1385,7 @@ describe('editor_frame', () => {
});
});
expect(mockVisualization2.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization2.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
state: suggestionVisState,
@ -1375,10 +1394,12 @@ describe('editor_frame', () => {
});
it('should use the highest priority suggestion available', async () => {
mockDatasource.getLayers.mockReturnValue(['first', 'second', 'third']);
const suggestionVisState = {};
const mockVisualization3 = {
...createMockVisualization(),
id: 'testVis3',
getLayerIds: () => ['third'],
visualizationTypes: [
{
icon: 'empty',
@ -1460,7 +1481,7 @@ describe('editor_frame', () => {
});
});
expect(mockVisualization3.renderConfigPanel).toHaveBeenCalledWith(
expect(mockVisualization3.renderLayerConfigPanel).toHaveBeenCalledWith(
expect.any(Element),
expect.objectContaining({
state: suggestionVisState,
@ -1633,13 +1654,16 @@ describe('editor_frame', () => {
await waitForPromises();
expect(onChange).toHaveBeenCalledTimes(2);
(instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: () => ({
newState: true,
}),
datasourceId: 'testDatasource',
act(() => {
(instance.find(FrameLayout).prop('dataPanel') as ReactElement)!.props.dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: () => ({
newState: true,
}),
datasourceId: 'testDatasource',
});
});
await waitForPromises();
expect(onChange).toHaveBeenCalledTimes(3);

View file

@ -52,6 +52,8 @@ export interface EditorFrameProps {
export function EditorFrame(props: EditorFrameProps) {
const [state, dispatch] = useReducer(reducer, props, getInitialState);
const { onError } = props;
const activeVisualization =
state.visualization.activeId && props.visualizationMap[state.visualization.activeId];
const allLoaded = Object.values(state.datasourceStates).every(
({ isLoading }) => typeof isLoading === 'boolean' && !isLoading
@ -125,7 +127,20 @@ export function EditorFrame(props: EditorFrameProps) {
return newLayerId;
},
removeLayers: (layerIds: string[]) => {
removeLayers(layerIds: string[]) {
if (activeVisualization && activeVisualization.removeLayer && state.visualization.state) {
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState: layerIds.reduce(
(acc, layerId) =>
activeVisualization.removeLayer ? activeVisualization.removeLayer(acc, layerId) : acc,
state.visualization.state
),
});
}
layerIds.forEach(layerId => {
const layerDatasourceId = Object.entries(props.datasourceMap).find(
([datasourceId, datasource]) =>
@ -158,16 +173,15 @@ export function EditorFrame(props: EditorFrameProps) {
// Initialize visualization as soon as all datasources are ready
useEffect(() => {
if (allLoaded && state.visualization.state === null && state.visualization.activeId !== null) {
const initialVisualizationState = props.visualizationMap[
state.visualization.activeId
].initialize(framePublicAPI);
if (allLoaded && state.visualization.state === null && activeVisualization) {
const initialVisualizationState = activeVisualization.initialize(framePublicAPI);
dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState: initialVisualizationState,
});
}
}, [allLoaded, state.visualization.activeId, state.visualization.state]);
}, [allLoaded, activeVisualization, state.visualization.state]);
// The frame needs to call onChange every time its internal state changes
useEffect(() => {
@ -176,11 +190,7 @@ export function EditorFrame(props: EditorFrameProps) {
? props.datasourceMap[state.activeDatasourceId]
: undefined;
const visualization = state.visualization.activeId
? props.visualizationMap[state.visualization.activeId]
: undefined;
if (!activeDatasource || !visualization) {
if (!activeDatasource || !activeVisualization) {
return;
}
@ -208,13 +218,14 @@ export function EditorFrame(props: EditorFrameProps) {
}),
{}
),
visualization,
visualization: activeVisualization,
state,
framePublicAPI,
});
props.onChange({ filterableIndexPatterns: indexPatterns, doc });
}, [
activeVisualization,
state.datasourceStates,
state.visualization,
props.query,
@ -248,6 +259,7 @@ export function EditorFrame(props: EditorFrameProps) {
configPanel={
allLoaded && (
<ConfigPanelWrapper
activeDatasourceId={state.activeDatasourceId!}
datasourceMap={props.datasourceMap}
datasourceStates={state.datasourceStates}
visualizationMap={props.visualizationMap}

View file

@ -0,0 +1,115 @@
/*
* 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 { removeLayer, appendLayer } from './layer_actions';
function createTestArgs(initialLayerIds: string[]) {
const trackUiEvent = jest.fn();
const testDatasource = (datasourceId: string) => ({
id: datasourceId,
clearLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).map((id: string) =>
id === layerId ? `${datasourceId}_clear_${layerId}` : id
),
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
});
const activeVisualization = {
clearLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).map((id: string) => (id === layerId ? `vis_clear_${layerId}` : id)),
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
getLayerIds: (layerIds: unknown) => layerIds as string[],
appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
};
return {
state: {
activeDatasourceId: 'ds1',
datasourceStates: {
ds1: {
isLoading: false,
state: initialLayerIds.slice(0, 1),
},
ds2: {
isLoading: false,
state: initialLayerIds.slice(1),
},
},
title: 'foo',
visualization: {
activeId: 'vis1',
state: initialLayerIds,
},
},
activeVisualization,
datasourceMap: {
ds1: testDatasource('ds1'),
ds2: testDatasource('ds2'),
},
trackUiEvent,
};
}
describe('removeLayer', () => {
it('should clear the layer if it is the only layer', () => {
const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs(['layer1']);
const newState = removeLayer({
activeVisualization,
datasourceMap,
layerId: 'layer1',
state,
trackUiEvent,
});
expect(newState.visualization.state).toEqual(['vis_clear_layer1']);
expect(newState.datasourceStates.ds1.state).toEqual(['ds1_clear_layer1']);
expect(newState.datasourceStates.ds2.state).toEqual([]);
expect(trackUiEvent).toHaveBeenCalledWith('layer_cleared');
});
it('should remove the layer if it is not the only layer', () => {
const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([
'layer1',
'layer2',
]);
const newState = removeLayer({
activeVisualization,
datasourceMap,
layerId: 'layer1',
state,
trackUiEvent,
});
expect(newState.visualization.state).toEqual(['layer2']);
expect(newState.datasourceStates.ds1.state).toEqual([]);
expect(newState.datasourceStates.ds2.state).toEqual(['layer2']);
expect(trackUiEvent).toHaveBeenCalledWith('layer_removed');
});
});
describe('appendLayer', () => {
it('should add the layer to the datasource and visualization', () => {
const { state, trackUiEvent, datasourceMap, activeVisualization } = createTestArgs([
'layer1',
'layer2',
]);
const newState = appendLayer({
activeDatasource: datasourceMap.ds1,
activeVisualization,
generateId: () => 'foo',
state,
trackUiEvent,
});
expect(newState.visualization.state).toEqual(['layer1', 'layer2', 'foo']);
expect(newState.datasourceStates.ds1.state).toEqual(['layer1', 'foo']);
expect(newState.datasourceStates.ds2.state).toEqual(['layer2']);
expect(trackUiEvent).toHaveBeenCalledWith('layer_added');
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 _ from 'lodash';
import { EditorFrameState } from './state_management';
import { Datasource, Visualization } from '../../types';
interface RemoveLayerOptions {
trackUiEvent: (name: string) => void;
state: EditorFrameState;
layerId: string;
activeVisualization: Pick<Visualization, 'getLayerIds' | 'clearLayer' | 'removeLayer'>;
datasourceMap: Record<string, Pick<Datasource, 'clearLayer' | 'removeLayer'>>;
}
interface AppendLayerOptions {
trackUiEvent: (name: string) => void;
state: EditorFrameState;
generateId: () => string;
activeDatasource: Pick<Datasource, 'insertLayer' | 'id'>;
activeVisualization: Pick<Visualization, 'appendLayer'>;
}
export function removeLayer(opts: RemoveLayerOptions): EditorFrameState {
const { state, trackUiEvent: trackUiEvent, activeVisualization, layerId, datasourceMap } = opts;
const isOnlyLayer = activeVisualization
.getLayerIds(state.visualization.state)
.every(id => id === opts.layerId);
trackUiEvent(isOnlyLayer ? 'layer_cleared' : 'layer_removed');
return {
...state,
datasourceStates: _.mapValues(state.datasourceStates, (datasourceState, datasourceId) => {
const datasource = datasourceMap[datasourceId!];
return {
...datasourceState,
state: isOnlyLayer
? datasource.clearLayer(datasourceState.state, layerId)
: datasource.removeLayer(datasourceState.state, layerId),
};
}),
visualization: {
...state.visualization,
state:
isOnlyLayer || !activeVisualization.removeLayer
? activeVisualization.clearLayer(state.visualization.state, layerId)
: activeVisualization.removeLayer(state.visualization.state, layerId),
},
};
}
export function appendLayer({
trackUiEvent,
activeVisualization,
state,
generateId,
activeDatasource,
}: AppendLayerOptions): EditorFrameState {
trackUiEvent('layer_added');
if (!activeVisualization.appendLayer) {
return state;
}
const layerId = generateId();
return {
...state,
datasourceStates: {
...state.datasourceStates,
[activeDatasource.id]: {
...state.datasourceStates[activeDatasource.id],
state: activeDatasource.insertLayer(
state.datasourceStates[activeDatasource.id].state,
layerId
),
},
},
visualization: {
...state.visualization,
state: activeVisualization.appendLayer(state.visualization.state, layerId),
},
};
}

View file

@ -119,6 +119,7 @@ describe('editor_frame state management', () => {
},
{
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: 'testVis',
newState: newVisState,
}
);

View file

@ -31,6 +31,13 @@ export type Action =
type: 'UPDATE_TITLE';
title: string;
}
| {
type: 'UPDATE_STATE';
// Just for diagnostics, so we can determine what action
// caused this update.
subType: string;
updater: (prevState: EditorFrameState) => EditorFrameState;
}
| {
type: 'UPDATE_DATASOURCE_STATE';
updater: unknown | ((prevState: unknown) => unknown);
@ -39,6 +46,7 @@ export type Action =
}
| {
type: 'UPDATE_VISUALIZATION_STATE';
visualizationId: string;
newState: unknown;
clearStagedPreview?: boolean;
}
@ -128,6 +136,8 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
return action.state;
case 'UPDATE_TITLE':
return { ...state, title: action.title };
case 'UPDATE_STATE':
return action.updater(state);
case 'UPDATE_LAYER':
return {
...state,
@ -249,6 +259,12 @@ export const reducer = (state: EditorFrameState, action: Action): EditorFrameSta
if (!state.visualization.activeId) {
throw new Error('Invariant: visualization state got updated without active visualization');
}
// This is a safeguard that prevents us from accidentally updating the
// wrong visualization. This occurs in some cases due to the uncoordinated
// way we manage state across plugins.
if (state.visualization.activeId !== action.visualizationId) {
return state;
}
return {
...state,
visualization: {

View file

@ -10,7 +10,6 @@ import { IconType } from '@elastic/eui/src/components/icon/icon';
import {
Visualization,
Datasource,
FramePublicAPI,
TableChangeType,
TableSuggestion,
DatasourceSuggestion,
@ -130,7 +129,6 @@ function getVisualizationSuggestions(
}
export function switchToSuggestion(
frame: FramePublicAPI,
dispatch: (action: Action) => void,
suggestion: Pick<
Suggestion,
@ -145,5 +143,6 @@ export function switchToSuggestion(
datasourceState: suggestion.datasourceState,
datasourceId: suggestion.datasourceId!,
};
dispatch(action);
}

View file

@ -320,7 +320,7 @@ export function SuggestionPanel({
} else {
trackSuggestionEvent(`position_${index}_of_${suggestions.length}`);
setLastSelectedSuggestion(index);
switchToSuggestion(frame, dispatch, suggestion);
switchToSuggestion(dispatch, suggestion);
}
}}
selected={index === lastSelectedSuggestion}

View file

@ -6,7 +6,7 @@
import React from 'react';
import { ExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public';
import { Visualization, FramePublicAPI, TableSuggestion } from '../../types';
import { FramePublicAPI, TableSuggestion, Visualization } from '../../types';
import {
createMockVisualization,
createMockDatasource,

View file

@ -126,12 +126,7 @@ export function InnerWorkspacePanel({
if (suggestionForDraggedField) {
trackUiEvent('drop_onto_workspace');
trackUiEvent(expression ? 'drop_non_empty' : 'drop_empty');
switchToSuggestion(
framePublicAPI,
dispatch,
suggestionForDraggedField,
'SWITCH_VISUALIZATION'
);
switchToSuggestion(dispatch, suggestionForDraggedField, 'SWITCH_VISUALIZATION');
}
}

View file

@ -14,12 +14,14 @@ import {
import { embeddablePluginMock } from '../../../../../../src/plugins/embeddable/public/mocks';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks';
import { DatasourcePublicAPI, FramePublicAPI, Visualization, Datasource } from '../types';
import { DatasourcePublicAPI, FramePublicAPI, Datasource, Visualization } from '../types';
import { EditorFrameSetupPlugins, EditorFrameStartPlugins } from './plugin';
export function createMockVisualization(): jest.Mocked<Visualization> {
return {
id: 'TEST_VIS',
clearLayer: jest.fn((state, _layerId) => state),
getLayerIds: jest.fn(_state => ['layer1']),
visualizationTypes: [
{
icon: 'empty',
@ -32,7 +34,7 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
getPersistableState: jest.fn(_state => _state),
getSuggestions: jest.fn(_options => []),
initialize: jest.fn((_frame, _state?) => ({})),
renderConfigPanel: jest.fn(),
renderLayerConfigPanel: jest.fn(),
toExpression: jest.fn((_state, _frame) => null),
toPreviewExpression: jest.fn((_state, _frame) => null),
};
@ -52,7 +54,8 @@ export function createMockDatasource(): DatasourceMock {
return {
id: 'mockindexpattern',
getDatasourceSuggestionsForField: jest.fn((_state, item) => []),
clearLayer: jest.fn((state, _layerId) => state),
getDatasourceSuggestionsForField: jest.fn((_state, _item) => []),
getDatasourceSuggestionsFromCurrentState: jest.fn(_state => []),
getPersistableState: jest.fn(),
getPublicAPI: jest.fn().mockReturnValue(publicAPIMock),

View file

@ -132,11 +132,7 @@ export function getIndexPatternDatasource({
...state,
layers: {
...state.layers,
[newLayerId]: {
indexPatternId: state.currentIndexPatternId,
columns: {},
columnOrder: [],
},
[newLayerId]: blankLayer(state.currentIndexPatternId),
},
};
},
@ -151,6 +147,16 @@ export function getIndexPatternDatasource({
};
},
clearLayer(state: IndexPatternPrivateState, layerId: string) {
return {
...state,
layers: {
...state.layers,
[layerId]: blankLayer(state.currentIndexPatternId),
},
};
},
getLayers(state: IndexPatternPrivateState) {
return Object.keys(state.layers);
},
@ -280,3 +286,11 @@ export function getIndexPatternDatasource({
return indexPatternDatasource;
}
function blankLayer(indexPatternId: string) {
return {
indexPatternId,
columns: {},
columnOrder: [],
};
}

View file

@ -38,6 +38,7 @@ describe('MetricConfigPanel', () => {
const state = testState();
const component = mount(
<MetricConfigPanel
layerId="bar"
dragDropContext={dragDropContext}
setState={jest.fn()}
frame={{

View file

@ -6,41 +6,34 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiPanel, EuiSpacer } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import { State } from './types';
import { VisualizationProps, OperationMetadata } from '../types';
import { VisualizationLayerConfigProps, OperationMetadata } from '../types';
import { NativeRenderer } from '../native_renderer';
const isMetric = (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number';
export function MetricConfigPanel(props: VisualizationProps<State>) {
const { state, frame } = props;
const [datasource] = Object.values(frame.datasourceLayers);
const [layerId] = Object.keys(frame.datasourceLayers);
export function MetricConfigPanel(props: VisualizationLayerConfigProps<State>) {
const { state, frame, layerId } = props;
const datasource = frame.datasourceLayers[layerId];
return (
<EuiPanel className="lnsConfigPanel__panel" paddingSize="s">
<NativeRenderer render={datasource.renderLayerPanel} nativeProps={{ layerId }} />
<EuiSpacer size="s" />
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.metric.valueLabel', {
defaultMessage: 'Value',
})}
>
<NativeRenderer
data-test-subj={'lns_metric_valueDimensionPanel'}
render={datasource.renderDimensionPanel}
nativeProps={{
layerId,
columnId: state.accessor,
dragDropContext: props.dragDropContext,
filterOperations: isMetric,
}}
/>
</EuiFormRow>
</EuiPanel>
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.metric.valueLabel', {
defaultMessage: 'Value',
})}
>
<NativeRenderer
data-test-subj={'lns_metric_valueDimensionPanel'}
render={datasource.renderDimensionPanel}
nativeProps={{
layerId,
columnId: state.accessor,
dragDropContext: props.dragDropContext,
filterOperations: isMetric,
}}
/>
</EuiFormRow>
);
}

View file

@ -50,6 +50,22 @@ describe('metric_visualization', () => {
});
});
describe('#getLayerIds', () => {
it('returns the layer id', () => {
expect(metricVisualization.getLayerIds(exampleState())).toEqual(['l1']);
});
});
describe('#clearLayer', () => {
it('returns a clean layer', () => {
(generateId as jest.Mock).mockReturnValueOnce('test-id1');
expect(metricVisualization.clearLayer(exampleState(), 'l1')).toEqual({
accessor: 'test-id1',
layerId: 'l1',
});
});
});
describe('#getPersistableState', () => {
it('persists the state as given', () => {
expect(metricVisualization.getPersistableState(exampleState())).toEqual(exampleState());

View file

@ -54,6 +54,17 @@ export const metricVisualization: Visualization<State, PersistableState> = {
},
],
clearLayer(state) {
return {
...state,
accessor: generateId(),
};
},
getLayerIds(state) {
return [state.layerId];
},
getDescription() {
return {
icon: chartMetricSVG,
@ -76,7 +87,7 @@ export const metricVisualization: Visualization<State, PersistableState> = {
getPersistableState: state => state,
renderConfigPanel: (domElement, props) =>
renderLayerConfigPanel: (domElement, props) =>
render(
<I18nProvider>
<MetricConfigPanel {...props} />

View file

@ -135,6 +135,7 @@ export interface Datasource<T = unknown, P = unknown> {
insertLayer: (state: T, newLayerId: string) => T;
removeLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string) => T;
getLayers: (state: T) => string[];
renderDataPanel: (domElement: Element, props: DatasourceDataPanelProps<T>) => void;
@ -237,7 +238,8 @@ export interface LensMultiTable {
};
}
export interface VisualizationProps<T = unknown> {
export interface VisualizationLayerConfigProps<T = unknown> {
layerId: string;
dragDropContext: DragContextState;
frame: FramePublicAPI;
state: T;
@ -325,6 +327,18 @@ export interface Visualization<T = unknown, P = unknown> {
visualizationTypes: VisualizationType[];
getLayerIds: (state: T) => string[];
clearLayer: (state: T, layerId: string) => T;
removeLayer?: (state: T, layerId: string) => T;
appendLayer?: (state: T, layerId: string) => T;
getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined;
renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerConfigProps<T>) => void;
getDescription: (
state: T
) => {
@ -339,7 +353,7 @@ export interface Visualization<T = unknown, P = unknown> {
getPersistableState: (state: T) => P;
renderConfigPanel: (domElement: Element, props: VisualizationProps<T>) => void;
renderLayerConfigPanel: (domElement: Element, props: VisualizationLayerConfigProps<T>) => void;
toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null;

View file

@ -8,9 +8,9 @@ import React from 'react';
import { ReactWrapper } from 'enzyme';
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
import { EuiButtonGroupProps } from '@elastic/eui';
import { XYConfigPanel } from './xy_config_panel';
import { XYConfigPanel, LayerContextMenu } from './xy_config_panel';
import { DatasourceDimensionPanelProps, Operation, FramePublicAPI } from '../types';
import { State, XYState } from './types';
import { State } from './types';
import { Position } from '@elastic/charts';
import { NativeRendererProps } from '../native_renderer';
import { generateId } from '../id_generator';
@ -46,15 +46,6 @@ describe('XYConfigPanel', () => {
.props();
}
function openComponentPopover(component: ReactWrapper<unknown>, layerId: string) {
component
.find(`[data-test-subj="lnsXY_layer_${layerId}"]`)
.first()
.find(`[data-test-subj="lnsXY_layer_advanced"]`)
.first()
.simulate('click');
}
beforeEach(() => {
frame = createMockFramePublicAPI();
frame.datasourceLayers = {
@ -67,55 +58,55 @@ describe('XYConfigPanel', () => {
test.skip('allows toggling the y axis gridlines', () => {});
test.skip('allows toggling the x axis gridlines', () => {});
test('enables stacked chart types even when there is no split series', () => {
const state = testState();
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
state={{ ...state, layers: [{ ...state.layers[0], xAccessor: 'shazm' }] }}
/>
);
describe('LayerContextMenu', () => {
test('enables stacked chart types even when there is no split series', () => {
const state = testState();
const component = mount(
<LayerContextMenu
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
state={{ ...state, layers: [{ ...state.layers[0], xAccessor: 'shazm' }] }}
/>
);
openComponentPopover(component, 'first');
const options = component
.find('[data-test-subj="lnsXY_seriesType"]')
.first()
.prop('options') as EuiButtonGroupProps['options'];
const options = component
.find('[data-test-subj="lnsXY_seriesType"]')
.first()
.prop('options') as EuiButtonGroupProps['options'];
expect(options!.map(({ id }) => id)).toEqual([
'bar',
'bar_stacked',
'line',
'area',
'area_stacked',
]);
expect(options!.map(({ id }) => id)).toEqual([
'bar',
'bar_stacked',
'line',
'area',
'area_stacked',
]);
expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]);
});
expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]);
});
test('shows only horizontal bar options when in horizontal mode', () => {
const state = testState();
const component = mount(
<LayerContextMenu
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
state={{ ...state, layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }] }}
/>
);
test('shows only horizontal bar options when in horizontal mode', () => {
const state = testState();
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
state={{ ...state, layers: [{ ...state.layers[0], seriesType: 'bar_horizontal' }] }}
/>
);
const options = component
.find('[data-test-subj="lnsXY_seriesType"]')
.first()
.prop('options') as EuiButtonGroupProps['options'];
openComponentPopover(component, 'first');
const options = component
.find('[data-test-subj="lnsXY_seriesType"]')
.first()
.prop('options') as EuiButtonGroupProps['options'];
expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']);
expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]);
expect(options!.map(({ id }) => id)).toEqual(['bar_horizontal', 'bar_horizontal_stacked']);
expect(options!.filter(({ isDisabled }) => isDisabled).map(({ id }) => id)).toEqual([]);
});
});
test('the x dimension panel accepts only bucketed operations', () => {
@ -123,6 +114,7 @@ describe('XYConfigPanel', () => {
const state = testState();
const component = mount(
<XYConfigPanel
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
@ -159,6 +151,7 @@ describe('XYConfigPanel', () => {
const state = testState();
const component = mount(
<XYConfigPanel
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={jest.fn()}
@ -190,6 +183,7 @@ describe('XYConfigPanel', () => {
const state = testState();
const component = mount(
<XYConfigPanel
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
@ -197,8 +191,6 @@ describe('XYConfigPanel', () => {
/>
);
openComponentPopover(component, 'first');
const onRemove = component
.find('[data-test-subj="lensXY_yDimensionPanel"]')
.first()
@ -223,6 +215,7 @@ describe('XYConfigPanel', () => {
const state = testState();
const component = mount(
<XYConfigPanel
layerId={state.layers[0].layerId}
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
@ -247,165 +240,4 @@ describe('XYConfigPanel', () => {
],
});
});
describe('layers', () => {
it('adds layers', () => {
frame.addNewLayer = jest.fn().mockReturnValue('newLayerId');
(generateId as jest.Mock).mockReturnValue('accessor');
const setState = jest.fn();
const state = testState();
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
state={state}
/>
);
component
.find('[data-test-subj="lnsXY_layer_add"]')
.first()
.simulate('click');
expect(frame.addNewLayer).toHaveBeenCalled();
expect(setState).toHaveBeenCalledTimes(1);
expect(generateId).toHaveBeenCalledTimes(4);
expect(setState.mock.calls[0][0]).toMatchObject({
layers: [
...state.layers,
expect.objectContaining({
layerId: 'newLayerId',
xAccessor: 'accessor',
accessors: ['accessor'],
splitAccessor: 'accessor',
}),
],
});
});
it('should use series type of existing layers if they all have the same', () => {
frame.addNewLayer = jest.fn().mockReturnValue('newLayerId');
frame.datasourceLayers.second = createMockDatasource().publicAPIMock;
(generateId as jest.Mock).mockReturnValue('accessor');
const setState = jest.fn();
const state: XYState = {
...testState(),
preferredSeriesType: 'bar',
layers: [
{
seriesType: 'line',
layerId: 'first',
splitAccessor: 'baz',
xAccessor: 'foo',
accessors: ['bar'],
},
{
seriesType: 'line',
layerId: 'second',
splitAccessor: 'baz',
xAccessor: 'foo',
accessors: ['bar'],
},
],
};
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
state={state}
/>
);
component
.find('[data-test-subj="lnsXY_layer_add"]')
.first()
.simulate('click');
expect(setState.mock.calls[0][0]).toMatchObject({
layers: [
...state.layers,
expect.objectContaining({
seriesType: 'line',
}),
],
});
});
it('should use preffered series type if there are already various different layers', () => {
frame.addNewLayer = jest.fn().mockReturnValue('newLayerId');
frame.datasourceLayers.second = createMockDatasource().publicAPIMock;
(generateId as jest.Mock).mockReturnValue('accessor');
const setState = jest.fn();
const state: XYState = {
...testState(),
preferredSeriesType: 'bar',
layers: [
{
seriesType: 'area',
layerId: 'first',
splitAccessor: 'baz',
xAccessor: 'foo',
accessors: ['bar'],
},
{
seriesType: 'line',
layerId: 'second',
splitAccessor: 'baz',
xAccessor: 'foo',
accessors: ['bar'],
},
],
};
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
state={state}
/>
);
component
.find('[data-test-subj="lnsXY_layer_add"]')
.first()
.simulate('click');
expect(setState.mock.calls[0][0]).toMatchObject({
layers: [
...state.layers,
expect.objectContaining({
seriesType: 'bar',
}),
],
});
});
it('removes layers', () => {
const setState = jest.fn();
const state = testState();
const component = mount(
<XYConfigPanel
dragDropContext={dragDropContext}
frame={frame}
setState={setState}
state={state}
/>
);
openComponentPopover(component, 'first');
component
.find('[data-test-subj="lnsXY_layer_remove"]')
.first()
.simulate('click');
expect(frame.removeLayers).toHaveBeenCalled();
expect(setState).toHaveBeenCalledTimes(1);
expect(setState.mock.calls[0][0]).toMatchObject({
layers: [],
});
});
});
});

View file

@ -5,25 +5,11 @@
*/
import _ from 'lodash';
import React, { useState } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButton,
EuiButtonGroup,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiPanel,
EuiButtonIcon,
EuiPopover,
EuiSpacer,
EuiButtonEmpty,
EuiPopoverFooter,
EuiToolTip,
} from '@elastic/eui';
import { State, SeriesType, LayerConfig, visualizationTypes } from './types';
import { VisualizationProps, OperationMetadata } from '../types';
import { EuiButtonGroup, EuiFormRow } from '@elastic/eui';
import { State, SeriesType, visualizationTypes } from './types';
import { VisualizationLayerConfigProps, OperationMetadata } from '../types';
import { NativeRenderer } from '../native_renderer';
import { MultiColumnEditor } from '../multi_column_editor';
import { generateId } from '../id_generator';
@ -45,253 +31,140 @@ function updateLayer(state: State, layer: UnwrapArray<State['layers']>, index: n
};
}
function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig {
return {
layerId,
seriesType,
xAccessor: generateId(),
accessors: [generateId()],
splitAccessor: generateId(),
};
}
export function LayerContextMenu(props: VisualizationLayerConfigProps<State>) {
const { state, layerId } = props;
const horizontalOnly = isHorizontalChart(state.layers);
const index = state.layers.findIndex(l => l.layerId === layerId);
const layer = state.layers[index];
function LayerSettings({
layer,
horizontalOnly,
setSeriesType,
removeLayer,
}: {
layer: LayerConfig;
horizontalOnly: boolean;
setSeriesType: (seriesType: SeriesType) => void;
removeLayer: () => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const { icon } = visualizationTypes.find(c => c.id === layer.seriesType)!;
if (!layer) {
return null;
}
return (
<EuiPopover
id={`lnsXYSeriesTypePopover_${layer.layerId}`}
panelPaddingSize="s"
ownFocus
button={
<EuiButtonIcon
iconType={icon || 'lnsBarVertical'}
aria-label={i18n.translate('xpack.lens.xyChart.layerSettings', {
defaultMessage: 'Edit layer settings',
})}
onClick={() => setIsOpen(!isOpen)}
data-test-subj="lnsXY_layer_advanced"
/>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
anchorPosition="leftUp"
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.chartTypeLabel', {
defaultMessage: 'Chart type',
})}
>
<EuiFormRow
label={i18n.translate('xpack.lens.xyChart.chartTypeLabel', {
<EuiButtonGroup
legend={i18n.translate('xpack.lens.xyChart.chartTypeLegend', {
defaultMessage: 'Chart type',
})}
>
<EuiButtonGroup
legend={i18n.translate('xpack.lens.xyChart.chartTypeLegend', {
defaultMessage: 'Chart type',
})}
name="chartType"
className="eui-displayInlineBlock"
data-test-subj="lnsXY_seriesType"
options={visualizationTypes
.filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly)
.map(t => ({
id: t.id,
label: t.label,
iconType: t.icon || 'empty',
}))}
idSelected={layer.seriesType}
onChange={seriesType => {
trackUiEvent('xy_change_layer_display');
setSeriesType(seriesType as SeriesType);
}}
isIconOnly
buttonSize="compressed"
/>
</EuiFormRow>
<EuiPopoverFooter className="eui-textCenter">
<EuiButtonEmpty
size="xs"
iconType="trash"
color="danger"
data-test-subj="lnsXY_layer_remove"
onClick={removeLayer}
>
{i18n.translate('xpack.lens.xyChart.deleteLayer', {
defaultMessage: 'Delete layer',
})}
</EuiButtonEmpty>
</EuiPopoverFooter>
</EuiPopover>
name="chartType"
className="eui-displayInlineBlock"
data-test-subj="lnsXY_seriesType"
options={visualizationTypes
.filter(t => isHorizontalSeries(t.id as SeriesType) === horizontalOnly)
.map(t => ({
id: t.id,
label: t.label,
iconType: t.icon || 'empty',
}))}
idSelected={layer.seriesType}
onChange={seriesType => {
trackUiEvent('xy_change_layer_display');
props.setState(
updateLayer(state, { ...layer, seriesType: seriesType as SeriesType }, index)
);
}}
isIconOnly
buttonSize="compressed"
/>
</EuiFormRow>
);
}
export function XYConfigPanel(props: VisualizationProps<State>) {
const { state, setState, frame } = props;
const horizontalOnly = isHorizontalChart(state.layers);
export function XYConfigPanel(props: VisualizationLayerConfigProps<State>) {
const { state, setState, frame, layerId } = props;
const index = props.state.layers.findIndex(l => l.layerId === layerId);
if (index < 0) {
return null;
}
const layer = props.state.layers[index];
return (
<EuiForm className="lnsConfigPanel">
{state.layers.map((layer, index) => (
<EuiPanel
className="lnsConfigPanel__panel"
key={layer.layerId}
data-test-subj={`lnsXY_layer_${layer.layerId}`}
paddingSize="s"
>
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<LayerSettings
layer={layer}
horizontalOnly={horizontalOnly}
setSeriesType={seriesType =>
setState(updateLayer(state, { ...layer, seriesType }, index))
}
removeLayer={() => {
trackUiEvent('xy_layer_removed');
frame.removeLayers([layer.layerId]);
setState({ ...state, layers: state.layers.filter(l => l !== layer) });
}}
/>
</EuiFlexItem>
<EuiFlexItem className="eui-textTruncate">
<NativeRenderer
data-test-subj="lnsXY_layerHeader"
render={props.frame.datasourceLayers[layer.layerId].renderLayerPanel}
nativeProps={{ layerId: layer.layerId }}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xs" />
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.xyChart.xAxisLabel', {
defaultMessage: 'X-axis',
})}
>
<NativeRenderer
data-test-subj="lnsXY_xDimensionPanel"
render={props.frame.datasourceLayers[layer.layerId].renderDimensionPanel}
nativeProps={{
columnId: layer.xAccessor,
dragDropContext: props.dragDropContext,
filterOperations: isBucketed,
suggestedPriority: 1,
layerId: layer.layerId,
hideGrouping: true,
}}
/>
</EuiFormRow>
<EuiFormRow
className="lnsConfigPanel__axis"
data-test-subj="lnsXY_yDimensionPanel"
label={i18n.translate('xpack.lens.xyChart.yAxisLabel', {
defaultMessage: 'Y-axis',
})}
>
<MultiColumnEditor
accessors={layer.accessors}
datasource={frame.datasourceLayers[layer.layerId]}
dragDropContext={props.dragDropContext}
onAdd={() =>
setState(
updateLayer(
state,
{
...layer,
accessors: [...layer.accessors, generateId()],
},
index
)
)
}
onRemove={accessor =>
setState(
updateLayer(
state,
{
...layer,
accessors: layer.accessors.filter(col => col !== accessor),
},
index
)
)
}
filterOperations={isNumericMetric}
data-test-subj="lensXY_yDimensionPanel"
testSubj="lensXY_yDimensionPanel"
layerId={layer.layerId}
/>
</EuiFormRow>
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.xyChart.splitSeries', {
defaultMessage: 'Break down by',
})}
>
<NativeRenderer
data-test-subj="lnsXY_splitDimensionPanel"
render={props.frame.datasourceLayers[layer.layerId].renderDimensionPanel}
nativeProps={{
columnId: layer.splitAccessor,
dragDropContext: props.dragDropContext,
filterOperations: isBucketed,
suggestedPriority: 0,
layerId: layer.layerId,
}}
/>
</EuiFormRow>
</EuiPanel>
))}
<EuiFlexItem grow={true}>
<EuiToolTip
className="eui-fullWidth"
content={i18n.translate('xpack.lens.xyChart.addLayerTooltip', {
defaultMessage:
'Use multiple layers to combine chart types or visualize different index patterns.',
})}
position="bottom"
>
<EuiButton
className="lnsConfigPanel__addLayerBtn"
fullWidth
size="s"
data-test-subj={`lnsXY_layer_add`}
aria-label={i18n.translate('xpack.lens.xyChart.addLayerButton', {
defaultMessage: 'Add layer',
})}
title={i18n.translate('xpack.lens.xyChart.addLayerButton', {
defaultMessage: 'Add layer',
})}
onClick={() => {
trackUiEvent('xy_layer_added');
const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType));
setState({
...state,
layers: [
...state.layers,
newLayerState(
usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType,
frame.addNewLayer()
),
],
});
}}
iconType="plusInCircleFilled"
/>
</EuiToolTip>
</EuiFlexItem>
</EuiForm>
<>
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.xyChart.xAxisLabel', {
defaultMessage: 'X-axis',
})}
>
<NativeRenderer
data-test-subj="lnsXY_xDimensionPanel"
render={props.frame.datasourceLayers[layer.layerId].renderDimensionPanel}
nativeProps={{
columnId: layer.xAccessor,
dragDropContext: props.dragDropContext,
filterOperations: isBucketed,
suggestedPriority: 1,
layerId: layer.layerId,
hideGrouping: true,
}}
/>
</EuiFormRow>
<EuiFormRow
className="lnsConfigPanel__axis"
data-test-subj="lnsXY_yDimensionPanel"
label={i18n.translate('xpack.lens.xyChart.yAxisLabel', {
defaultMessage: 'Y-axis',
})}
>
<MultiColumnEditor
accessors={layer.accessors}
datasource={frame.datasourceLayers[layer.layerId]}
dragDropContext={props.dragDropContext}
onAdd={() =>
setState(
updateLayer(
state,
{
...layer,
accessors: [...layer.accessors, generateId()],
},
index
)
)
}
onRemove={accessor =>
setState(
updateLayer(
state,
{
...layer,
accessors: layer.accessors.filter(col => col !== accessor),
},
index
)
)
}
filterOperations={isNumericMetric}
data-test-subj="lensXY_yDimensionPanel"
testSubj="lensXY_yDimensionPanel"
layerId={layer.layerId}
/>
</EuiFormRow>
<EuiFormRow
className="lnsConfigPanel__axis"
label={i18n.translate('xpack.lens.xyChart.splitSeries', {
defaultMessage: 'Break down by',
})}
>
<NativeRenderer
data-test-subj="lnsXY_splitDimensionPanel"
render={props.frame.datasourceLayers[layer.layerId].renderDimensionPanel}
nativeProps={{
columnId: layer.splitAccessor,
dragDropContext: props.dragDropContext,
filterOperations: isBucketed,
suggestedPriority: 0,
layerId: layer.layerId,
}}
/>
</EuiFormRow>
</>
);
}

View file

@ -137,6 +137,54 @@ describe('xy_visualization', () => {
});
});
describe('#removeLayer', () => {
it('removes the specified layer', () => {
const prevState: State = {
...exampleState(),
layers: [
...exampleState().layers,
{
layerId: 'second',
seriesType: 'area',
splitAccessor: 'e',
xAccessor: 'f',
accessors: ['g', 'h'],
},
],
};
expect(xyVisualization.removeLayer!(prevState, 'second')).toEqual(exampleState());
});
});
describe('#appendLayer', () => {
it('adds a layer', () => {
const layers = xyVisualization.appendLayer!(exampleState(), 'foo').layers;
expect(layers.length).toEqual(exampleState().layers.length + 1);
expect(layers[layers.length - 1]).toMatchObject({ layerId: 'foo' });
});
});
describe('#clearLayer', () => {
it('clears the specified layer', () => {
(generateId as jest.Mock).mockReturnValue('test_empty_id');
const layer = xyVisualization.clearLayer(exampleState(), 'first').layers[0];
expect(layer).toMatchObject({
accessors: ['test_empty_id'],
layerId: 'first',
seriesType: 'bar',
splitAccessor: 'test_empty_id',
xAccessor: 'test_empty_id',
});
});
});
describe('#getLayerIds', () => {
it('returns layerids', () => {
expect(xyVisualization.getLayerIds(exampleState())).toEqual(['first']);
});
});
describe('#toExpression', () => {
let mockDatasource: ReturnType<typeof createMockDatasource>;
let frame: ReturnType<typeof createMockFramePublicAPI>;

View file

@ -11,9 +11,9 @@ import { Position } from '@elastic/charts';
import { I18nProvider } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { getSuggestions } from './xy_suggestions';
import { XYConfigPanel } from './xy_config_panel';
import { XYConfigPanel, LayerContextMenu } from './xy_config_panel';
import { Visualization } from '../types';
import { State, PersistableState, SeriesType, visualizationTypes } from './types';
import { State, PersistableState, SeriesType, visualizationTypes, LayerConfig } from './types';
import { toExpression, toPreviewExpression } from './to_expression';
import { generateId } from '../id_generator';
import chartBarStackedSVG from '../assets/chart_bar_stacked.svg';
@ -67,6 +67,40 @@ export const xyVisualization: Visualization<State, PersistableState> = {
visualizationTypes,
getLayerIds(state) {
return state.layers.map(l => l.layerId);
},
removeLayer(state, layerId) {
return {
...state,
layers: state.layers.filter(l => l.layerId !== layerId),
};
},
appendLayer(state, layerId) {
const usedSeriesTypes = _.uniq(state.layers.map(layer => layer.seriesType));
return {
...state,
layers: [
...state.layers,
newLayerState(
usedSeriesTypes.length === 1 ? usedSeriesTypes[0] : state.preferredSeriesType,
layerId
),
],
};
},
clearLayer(state, layerId) {
return {
...state,
layers: state.layers.map(l =>
l.layerId !== layerId ? l : newLayerState(state.preferredSeriesType, layerId)
),
};
},
getDescription(state) {
const { icon, label } = getDescription(state);
const chartLabel = i18n.translate('xpack.lens.xyVisualization.chartLabel', {
@ -113,7 +147,7 @@ export const xyVisualization: Visualization<State, PersistableState> = {
getPersistableState: state => state,
renderConfigPanel: (domElement, props) =>
renderLayerConfigPanel: (domElement, props) =>
render(
<I18nProvider>
<XYConfigPanel {...props} />
@ -121,6 +155,30 @@ export const xyVisualization: Visualization<State, PersistableState> = {
domElement
),
getLayerContextMenuIcon({ state, layerId }) {
const layer = state.layers.find(l => l.layerId === layerId);
return visualizationTypes.find(t => t.id === layer?.seriesType)?.icon;
},
renderLayerContextMenu(domElement, props) {
render(
<I18nProvider>
<LayerContextMenu {...props} />
</I18nProvider>,
domElement
);
},
toExpression,
toPreviewExpression,
};
function newLayerState(seriesType: SeriesType, layerId: string): LayerConfig {
return {
layerId,
seriesType,
xAccessor: generateId(),
accessors: [generateId()],
splitAccessor: generateId(),
};
}

View file

@ -6963,10 +6963,8 @@
"xpack.lens.xyChart.addLayerTooltip": "複数のレイヤーを使用すると、グラフタイプを組み合わせたり、別のインデックスパターンを可視化したりすることができます。",
"xpack.lens.xyChart.chartTypeLabel": "チャートタイプ",
"xpack.lens.xyChart.chartTypeLegend": "チャートタイプ",
"xpack.lens.xyChart.deleteLayer": "レイヤーを削除",
"xpack.lens.xyChart.help": "X/Y チャート",
"xpack.lens.xyChart.isVisible.help": "判例の表示・非表示を指定します。",
"xpack.lens.xyChart.layerSettings": "レイヤー設定を編集",
"xpack.lens.xyChart.legend.help": "チャートの凡例を構成します。",
"xpack.lens.xyChart.nestUnderRoot": "データセット全体",
"xpack.lens.xyChart.position.help": "凡例の配置を指定します。",

View file

@ -6962,10 +6962,8 @@
"xpack.lens.xyChart.addLayerTooltip": "使用多个图层以组合图表类型或可视化不同的索引模式。",
"xpack.lens.xyChart.chartTypeLabel": "图表类型",
"xpack.lens.xyChart.chartTypeLegend": "图表类型",
"xpack.lens.xyChart.deleteLayer": "删除图层",
"xpack.lens.xyChart.help": "X/Y 图表",
"xpack.lens.xyChart.isVisible.help": "指定图例是否可见。",
"xpack.lens.xyChart.layerSettings": "编辑图层设置",
"xpack.lens.xyChart.legend.help": "配置图表图例。",
"xpack.lens.xyChart.nestUnderRoot": "整个数据集",
"xpack.lens.xyChart.position.help": "指定图例位置。",