[Lens] Split apart config panel component to more manageable chunks (#63910)

* [Lens] Split apart config panel component to more manageable chunks

* Moving around and renaming SASS appropriately

* Remove layer limit

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: cchaos <caroline.horn@elastic.co>
This commit is contained in:
Wylie Conlon 2020-04-22 17:36:36 -04:00 committed by GitHub
parent f7ea9b99ba
commit dd094f2333
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 836 additions and 727 deletions

View file

@ -0,0 +1,7 @@
.lnsConfigPanel__addLayerBtn {
color: transparentize($euiColorMediumShade, .3);
// Remove EuiButton's default shadow to make button more subtle
// sass-lint:disable-block no-important
box-shadow: none !important;
border: 1px dashed currentColor;
}

View file

@ -0,0 +1,9 @@
.lnsDimensionPopover {
line-height: 0;
flex-grow: 1;
}
.lnsDimensionPopover__trigger {
max-width: 100%;
display: block;
}

View file

@ -0,0 +1,4 @@
@import 'chart_switch';
@import 'config_panel';
@import 'dimension_popover';
@import 'layer_panel';

View file

@ -1,8 +1,8 @@
.lnsConfigPanel__panel {
.lnsLayerPanel {
margin-bottom: $euiSizeS;
}
.lnsConfigPanel__row {
.lnsLayerPanel__row {
background: $euiColorLightestShade;
padding: $euiSizeS;
border-radius: $euiBorderRadius;
@ -13,15 +13,7 @@
}
}
.lnsConfigPanel__addLayerBtn {
color: transparentize($euiColorMediumShade, .3);
// Remove EuiButton's default shadow to make button more subtle
// sass-lint:disable-block no-important
box-shadow: none !important;
border: 1px dashed currentColor;
}
.lnsConfigPanel__dimension {
.lnsLayerPanel__dimension {
@include euiFontSizeS;
background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade);
border-radius: $euiBorderRadius;
@ -31,12 +23,7 @@
overflow: hidden;
}
.lnsConfigPanel__trigger {
max-width: 100%;
display: block;
}
.lnsConfigPanel__triggerLink {
.lnsLayerPanel__triggerLink {
padding: $euiSizeS;
width: 100%;
display: flex;
@ -44,7 +31,3 @@
min-height: $euiSizeXXL;
}
.lnsConfigPanel__popover {
line-height: 0;
flex-grow: 1;
}

View file

@ -5,13 +5,17 @@
*/
import React from 'react';
import { createMockVisualization, createMockFramePublicAPI, createMockDatasource } from '../mocks';
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
import { ReactWrapper } from 'enzyme';
import { ChartSwitch } from './chart_switch';
import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../types';
import {
createMockVisualization,
createMockFramePublicAPI,
createMockDatasource,
} from '../../mocks';
import { EuiKeyPadMenuItem } from '@elastic/eui';
import { Action } from './state_management';
import { mountWithIntl as mount } from 'test_utils/enzyme_helpers';
import { Visualization, FramePublicAPI, DatasourcePublicAPI } from '../../../types';
import { Action } from '../state_management';
import { ChartSwitch } from './chart_switch';
describe('chart_switch', () => {
function generateVisualization(id: string): jest.Mocked<Visualization> {

View file

@ -15,10 +15,10 @@ import {
} from '@elastic/eui';
import { flatten } from 'lodash';
import { i18n } from '@kbn/i18n';
import { Visualization, FramePublicAPI, Datasource } from '../../types';
import { Action } from './state_management';
import { getSuggestions, switchToSuggestion, Suggestion } from './suggestion_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { Visualization, FramePublicAPI, Datasource } from '../../../types';
import { Action } from '../state_management';
import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers';
import { trackUiEvent } from '../../../lens_ui_telemetry';
interface VisualizationSelection {
visualizationId: string;

View file

@ -0,0 +1,177 @@
/*
* 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, { useMemo, memo } from 'react';
import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Visualization } from '../../../types';
import { ChartSwitch } from './chart_switch';
import { LayerPanel } from './layer_panel';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { generateId } from '../../../id_generator';
import { removeLayer, appendLayer } from './layer_actions';
import { ConfigPanelWrapperProps } from './types';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const activeVisualization = props.visualizationMap[props.activeVisualizationId || ''];
const { visualizationState } = props;
return (
<>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={props.visualizationMap}
visualizationId={props.activeVisualizationId}
visualizationState={props.visualizationState}
datasourceMap={props.datasourceMap}
datasourceStates={props.datasourceStates}
dispatch={props.dispatch}
framePublicAPI={props.framePublicAPI}
/>
{activeVisualization && visualizationState && (
<LayerPanels {...props} activeVisualization={activeVisualization} />
)}
</>
);
});
function LayerPanels(
props: ConfigPanelWrapperProps & {
activeDatasourceId: string;
activeVisualization: Visualization;
}
) {
const {
framePublicAPI,
activeVisualization,
visualizationState,
dispatch,
activeDatasourceId,
datasourceMap,
} = props;
const setVisualizationState = useMemo(
() => (newState: unknown) => {
props.dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState,
clearStagedPreview: false,
});
},
[props.dispatch, activeVisualization]
);
const updateDatasource = useMemo(
() => (datasourceId: string, newState: unknown) => {
props.dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: () => newState,
datasourceId,
clearStagedPreview: false,
});
},
[props.dispatch]
);
const updateAll = useMemo(
() => (datasourceId: string, newDatasourceState: unknown, newVisualizationState: unknown) => {
props.dispatch({
type: 'UPDATE_STATE',
subType: 'UPDATE_ALL_STATES',
updater: prevState => {
return {
...prevState,
datasourceStates: {
...prevState.datasourceStates,
[datasourceId]: {
state: newDatasourceState,
isLoading: false,
},
},
visualization: {
...prevState.visualization,
state: newVisualizationState,
},
stagedPreview: undefined,
};
},
});
},
[props.dispatch]
);
const layerIds = activeVisualization.getLayerIds(visualizationState);
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map(layerId => (
<LayerPanel
{...props}
key={layerId}
layerId={layerId}
activeVisualization={activeVisualization}
visualizationState={visualizationState}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateAll={updateAll}
frame={framePublicAPI}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: state =>
removeLayer({
activeVisualization,
layerId,
trackUiEvent,
datasourceMap,
state,
}),
});
}}
/>
))}
{activeVisualization.appendLayer && visualizationState && (
<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>
);
}

View file

@ -0,0 +1,49 @@
/*
* 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 { EuiPopover } from '@elastic/eui';
import { VisualizationDimensionGroupConfig } from '../../../types';
import { DimensionPopoverState } from './types';
export function DimensionPopover({
popoverState,
setPopoverState,
groups,
accessor,
groupId,
trigger,
panel,
}: {
popoverState: DimensionPopoverState;
setPopoverState: (newState: DimensionPopoverState) => void;
groups: VisualizationDimensionGroupConfig[];
accessor: string;
groupId: string;
trigger: React.ReactElement;
panel: React.ReactElement;
}) {
const noMatch = popoverState.isOpen ? !groups.some(d => d.accessors.includes(accessor)) : false;
return (
<EuiPopover
className="lnsDimensionPopover"
anchorClassName="lnsDimensionPopover__trigger"
isOpen={
popoverState.isOpen &&
(popoverState.openId === accessor || (noMatch && popoverState.addingToGroupId === groupId))
}
closePopover={() => {
setPopoverState({ isOpen: false, openId: null, addingToGroupId: null });
}}
button={trigger}
anchorPosition="leftUp"
withTitle
panelPaddingSize="s"
>
{panel}
</EuiPopover>
);
}

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { ConfigPanelWrapper } from './config_panel';

View file

@ -5,8 +5,8 @@
*/
import _ from 'lodash';
import { EditorFrameState } from './state_management';
import { Datasource, Visualization } from '../../types';
import { EditorFrameState } from '../state_management';
import { Datasource, Visualization } from '../../../types';
interface RemoveLayerOptions {
trackUiEvent: (name: string) => void;

View file

@ -0,0 +1,405 @@
/*
* 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, { useContext, useState } from 'react';
import {
EuiPanel,
EuiSpacer,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiFormRow,
} 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 { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { generateId } from '../../../id_generator';
import { ConfigPanelWrapperProps, DimensionPopoverState } from './types';
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: (
datasourceId: string,
newDatasourcestate: unknown,
newVisualizationState: unknown
) => void;
onRemoveLayer: () => void;
}
) {
const dragDropContext = useContext(DragContext);
const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
if (!datasourcePublicAPI) {
return null;
}
const layerVisualizationConfigProps = {
layerId,
dragDropContext,
state: props.visualizationState,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
};
const datasourceId = datasourcePublicAPI.datasourceId;
const layerDatasourceState = props.datasourceStates[datasourceId].state;
const layerDatasource = props.datasourceMap[datasourceId];
const layerDatasourceDropProps = {
layerId,
dragDropContext,
state: layerDatasourceState,
setState: (newState: unknown) => {
props.updateDatasource(datasourceId, newState);
},
};
const layerDatasourceConfigProps = {
...layerDatasourceDropProps,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
};
const [popoverState, setPopoverState] = useState<DimensionPopoverState>({
isOpen: false,
openId: null,
addingToGroupId: null,
});
const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps);
const isEmptyLayer = !groups.some(d => d.accessors.length > 0);
return (
<ChildDragDropProvider {...dragDropContext}>
<EuiPanel className="lnsLayerPanel" paddingSize="s">
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<LayerSettings
layerId={layerId}
layerConfigProps={{
...layerVisualizationConfigProps,
setState: props.updateVisualization,
}}
activeVisualization={activeVisualization}
/>
</EuiFlexItem>
{layerDatasource && (
<EuiFlexItem className="eui-textTruncate">
<NativeRenderer
render={layerDatasource.renderLayerPanel}
nativeProps={{
layerId,
state: layerDatasourceState,
setState: (updater: unknown) => {
const newState =
typeof updater === 'function' ? updater(layerDatasourceState) : updater;
// Look for removed columns
const nextPublicAPI = layerDatasource.getPublicAPI({
state: newState,
layerId,
dateRange: props.framePublicAPI.dateRange,
});
const nextTable = new Set(
nextPublicAPI.getTableSpec().map(({ columnId }) => columnId)
);
const removed = datasourcePublicAPI
.getTableSpec()
.map(({ columnId }) => columnId)
.filter(columnId => !nextTable.has(columnId));
let nextVisState = props.visualizationState;
removed.forEach(columnId => {
nextVisState = activeVisualization.removeDimension({
layerId,
columnId,
prevState: nextVisState,
});
});
props.updateAll(datasourceId, newState, nextVisState);
},
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="s" />
{groups.map((group, index) => {
const newId = generateId();
const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0;
return (
<EuiFormRow
className="lnsLayerPanel__row"
label={group.groupLabel}
key={index}
isInvalid={isMissing}
error={
isMissing
? i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', {
defaultMessage: 'Required dimension',
})
: []
}
>
<>
{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,
}}
/>
}
/>
<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,
})
);
}}
/>
</DragDrop>
))}
{group.supportsMoreColumns ? (
<DragDrop
className="lnsLayerPanel__dimension"
data-test-subj={group.dataTestSubj}
droppable={
dragDropContext.dragging &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: newId,
filterOperations: group.filterOperations,
})
}
onDrop={droppedItem => {
const dropSuccess = layerDatasource.onDrop({
...layerDatasourceDropProps,
droppedItem,
columnId: newId,
filterOperations: group.filterOperations,
});
if (dropSuccess) {
props.updateVisualization(
activeVisualization.setDimension({
layerId,
groupId: group.groupId,
columnId: newId,
prevState: props.visualizationState,
})
);
}
}}
>
<DimensionPopover
popoverState={popoverState}
setPopoverState={setPopoverState}
groups={groups}
accessor={newId}
groupId={group.groupId}
trigger={
<div className="lnsLayerPanel__triggerLink">
<EuiButtonEmpty
iconType="plusInCircleFilled"
data-test-subj="lns-empty-dimension"
aria-label={i18n.translate('xpack.lens.configure.addConfig', {
defaultMessage: 'Add a configuration',
})}
title={i18n.translate('xpack.lens.configure.addConfig', {
defaultMessage: 'Add a configuration',
})}
onClick={() => {
if (popoverState.isOpen) {
setPopoverState({
isOpen: false,
openId: null,
addingToGroupId: null,
});
} else {
setPopoverState({
isOpen: true,
openId: newId,
addingToGroupId: group.groupId,
});
}
}}
size="xs"
>
<FormattedMessage
id="xpack.lens.configure.emptyConfig"
defaultMessage="Drop a field here"
/>
</EuiButtonEmpty>
</div>
}
panel={
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionEditor}
nativeProps={{
...layerDatasourceConfigProps,
core: props.core,
columnId: newId,
filterOperations: group.filterOperations,
suggestedPriority: group.suggestedPriority,
setState: (newState: unknown) => {
props.updateAll(
datasourceId,
newState,
activeVisualization.setDimension({
layerId,
groupId: group.groupId,
columnId: newId,
prevState: props.visualizationState,
})
);
setPopoverState({
isOpen: true,
openId: newId,
addingToGroupId: null, // clear now that dimension exists
});
},
}}
/>
}
/>
</DragDrop>
) : null}
</>
</EuiFormRow>
);
})}
<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?.blur) {
el.blur();
}
onRemoveLayer();
}}
>
{isOnlyLayer
? i18n.translate('xpack.lens.resetLayer', {
defaultMessage: 'Reset layer',
})
: i18n.translate('xpack.lens.deleteLayer', {
defaultMessage: 'Delete layer',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</ChildDragDropProvider>
);
}

View file

@ -0,0 +1,53 @@
/*
* 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, { useState } from 'react';
import { EuiPopover, EuiButtonIcon } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { NativeRenderer } from '../../../native_renderer';
import { Visualization, VisualizationLayerWidgetProps } from '../../../types';
export function LayerSettings({
layerId,
activeVisualization,
layerConfigProps,
}: {
layerId: string;
activeVisualization: Visualization;
layerConfigProps: VisualizationLayerWidgetProps;
}) {
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

@ -0,0 +1,37 @@
/*
* 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 { Action } from '../state_management';
import {
Visualization,
FramePublicAPI,
Datasource,
DatasourceDimensionEditorProps,
} from '../../../types';
export interface ConfigPanelWrapperProps {
activeDatasourceId: string;
visualizationState: unknown;
visualizationMap: Record<string, Visualization>;
activeVisualizationId: string | null;
dispatch: (action: Action) => void;
framePublicAPI: FramePublicAPI;
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
core: DatasourceDimensionEditorProps['core'];
}
export interface DimensionPopoverState {
isOpen: boolean;
openId: string | null;
addingToGroupId: string | null;
}

View file

@ -1,655 +0,0 @@
/*
* 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, { useMemo, useContext, memo, useState } from 'react';
import {
EuiPanel,
EuiSpacer,
EuiPopover,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiToolTip,
EuiButton,
EuiForm,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { NativeRenderer } from '../../native_renderer';
import { Action } from './state_management';
import {
Visualization,
FramePublicAPI,
Datasource,
VisualizationLayerWidgetProps,
DatasourceDimensionEditorProps,
StateSetter,
} from '../../types';
import { DragContext, DragDrop, ChildDragDropProvider } 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;
dispatch: (action: Action) => void;
framePublicAPI: FramePublicAPI;
datasourceMap: Record<string, Datasource>;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
core: DatasourceDimensionEditorProps['core'];
}
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
const activeVisualization = props.visualizationMap[props.activeVisualizationId || ''];
const { visualizationState } = props;
return (
<>
<ChartSwitch
data-test-subj="lnsChartSwitcher"
visualizationMap={props.visualizationMap}
visualizationId={props.activeVisualizationId}
visualizationState={props.visualizationState}
datasourceMap={props.datasourceMap}
datasourceStates={props.datasourceStates}
dispatch={props.dispatch}
framePublicAPI={props.framePublicAPI}
/>
{activeVisualization && visualizationState && (
<LayerPanels {...props} activeVisualization={activeVisualization} />
)}
</>
);
});
function LayerPanels(
props: ConfigPanelWrapperProps & {
activeDatasourceId: string;
activeVisualization: Visualization;
}
) {
const {
framePublicAPI,
activeVisualization,
visualizationState,
dispatch,
activeDatasourceId,
datasourceMap,
} = props;
const setVisualizationState = useMemo(
() => (newState: unknown) => {
props.dispatch({
type: 'UPDATE_VISUALIZATION_STATE',
visualizationId: activeVisualization.id,
newState,
clearStagedPreview: false,
});
},
[props.dispatch, activeVisualization]
);
const updateDatasource = useMemo(
() => (datasourceId: string, newState: unknown) => {
props.dispatch({
type: 'UPDATE_DATASOURCE_STATE',
updater: () => newState,
datasourceId,
clearStagedPreview: false,
});
},
[props.dispatch]
);
const updateAll = useMemo(
() => (datasourceId: string, newDatasourceState: unknown, newVisualizationState: unknown) => {
props.dispatch({
type: 'UPDATE_STATE',
subType: 'UPDATE_ALL_STATES',
updater: prevState => {
return {
...prevState,
datasourceStates: {
...prevState.datasourceStates,
[datasourceId]: {
state: newDatasourceState,
isLoading: false,
},
},
visualization: {
...prevState.visualization,
state: newVisualizationState,
},
stagedPreview: undefined,
};
},
});
},
[props.dispatch]
);
const layerIds = activeVisualization.getLayerIds(visualizationState);
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map(layerId => (
<LayerPanel
{...props}
key={layerId}
layerId={layerId}
activeVisualization={activeVisualization}
visualizationState={visualizationState}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateAll={updateAll}
frame={framePublicAPI}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
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: Exclude<ConfigPanelWrapperProps, 'state' | 'setState'> & {
frame: FramePublicAPI;
layerId: string;
isOnlyLayer: boolean;
activeVisualization: Visualization;
visualizationState: unknown;
updateVisualization: StateSetter<unknown>;
updateDatasource: (datasourceId: string, newState: unknown) => void;
updateAll: (
datasourceId: string,
newDatasourcestate: unknown,
newVisualizationState: unknown
) => void;
onRemoveLayer: () => void;
}
) {
const dragDropContext = useContext(DragContext);
const { framePublicAPI, layerId, activeVisualization, isOnlyLayer, onRemoveLayer } = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
if (!datasourcePublicAPI) {
return null;
}
const layerVisualizationConfigProps = {
layerId,
dragDropContext,
state: props.visualizationState,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
};
const datasourceId = datasourcePublicAPI.datasourceId;
const layerDatasourceState = props.datasourceStates[datasourceId].state;
const layerDatasource = props.datasourceMap[datasourceId];
const layerDatasourceDropProps = {
layerId,
dragDropContext,
state: layerDatasourceState,
setState: (newState: unknown) => {
props.updateDatasource(datasourceId, newState);
},
};
const layerDatasourceConfigProps = {
...layerDatasourceDropProps,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
};
const [popoverState, setPopoverState] = useState<{
isOpen: boolean;
openId: string | null;
addingToGroupId: string | null;
}>({
isOpen: false,
openId: null,
addingToGroupId: null,
});
const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps);
const isEmptyLayer = !groups.some(d => d.accessors.length > 0);
function wrapInPopover(
id: string,
groupId: string,
trigger: React.ReactElement,
panel: React.ReactElement
) {
const noMatch = popoverState.isOpen ? !groups.some(d => d.accessors.includes(id)) : false;
return (
<EuiPopover
className="lnsConfigPanel__popover"
anchorClassName="lnsConfigPanel__trigger"
isOpen={
popoverState.isOpen &&
(popoverState.openId === id || (noMatch && popoverState.addingToGroupId === groupId))
}
closePopover={() => {
setPopoverState({ isOpen: false, openId: null, addingToGroupId: null });
}}
button={trigger}
anchorPosition="leftUp"
withTitle
panelPaddingSize="s"
>
{panel}
</EuiPopover>
);
}
return (
<ChildDragDropProvider {...dragDropContext}>
<EuiPanel className="lnsConfigPanel__panel" paddingSize="s">
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<LayerSettings
layerId={layerId}
layerConfigProps={{
...layerVisualizationConfigProps,
setState: props.updateVisualization,
}}
activeVisualization={activeVisualization}
/>
</EuiFlexItem>
{layerDatasource && (
<EuiFlexItem className="eui-textTruncate">
<NativeRenderer
render={layerDatasource.renderLayerPanel}
nativeProps={{
layerId,
state: layerDatasourceState,
setState: (updater: unknown) => {
const newState =
typeof updater === 'function' ? updater(layerDatasourceState) : updater;
// Look for removed columns
const nextPublicAPI = layerDatasource.getPublicAPI({
state: newState,
layerId,
dateRange: props.framePublicAPI.dateRange,
});
const nextTable = new Set(
nextPublicAPI.getTableSpec().map(({ columnId }) => columnId)
);
const removed = datasourcePublicAPI
.getTableSpec()
.map(({ columnId }) => columnId)
.filter(columnId => !nextTable.has(columnId));
let nextVisState = props.visualizationState;
removed.forEach(columnId => {
nextVisState = activeVisualization.removeDimension({
layerId,
columnId,
prevState: nextVisState,
});
});
props.updateAll(datasourceId, newState, nextVisState);
},
}}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="s" />
{groups.map((group, index) => {
const newId = generateId();
const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0;
return (
<EuiFormRow
className="lnsConfigPanel__row"
label={group.groupLabel}
key={index}
isInvalid={isMissing}
error={
isMissing
? i18n.translate('xpack.lens.editorFrame.requiredDimensionWarningLabel', {
defaultMessage: 'Required dimension',
})
: []
}
>
<>
{group.accessors.map(accessor => (
<DragDrop
key={accessor}
className="lnsConfigPanel__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,
});
}}
>
{wrapInPopover(
accessor,
group.groupId,
<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
});
}
},
}}
/>,
<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,
})
);
}}
/>
</DragDrop>
))}
{group.supportsMoreColumns ? (
<DragDrop
className="lnsConfigPanel__dimension"
data-test-subj={group.dataTestSubj}
droppable={
dragDropContext.dragging &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: newId,
filterOperations: group.filterOperations,
})
}
onDrop={droppedItem => {
const dropSuccess = layerDatasource.onDrop({
...layerDatasourceDropProps,
droppedItem,
columnId: newId,
filterOperations: group.filterOperations,
});
if (dropSuccess) {
props.updateVisualization(
activeVisualization.setDimension({
layerId,
groupId: group.groupId,
columnId: newId,
prevState: props.visualizationState,
})
);
}
}}
>
{wrapInPopover(
newId,
group.groupId,
<div className="lnsConfigPanel__triggerLink">
<EuiButtonEmpty
iconType="plusInCircleFilled"
data-test-subj="lns-empty-dimension"
aria-label={i18n.translate('xpack.lens.configure.addConfig', {
defaultMessage: 'Add a configuration',
})}
title={i18n.translate('xpack.lens.configure.addConfig', {
defaultMessage: 'Add a configuration',
})}
onClick={() => {
if (popoverState.isOpen) {
setPopoverState({
isOpen: false,
openId: null,
addingToGroupId: null,
});
} else {
setPopoverState({
isOpen: true,
openId: newId,
addingToGroupId: group.groupId,
});
}
}}
size="xs"
>
<FormattedMessage
id="xpack.lens.configure.emptyConfig"
defaultMessage="Drop a field here"
/>
</EuiButtonEmpty>
</div>,
<NativeRenderer
render={props.datasourceMap[datasourceId].renderDimensionEditor}
nativeProps={{
...layerDatasourceConfigProps,
core: props.core,
columnId: newId,
filterOperations: group.filterOperations,
suggestedPriority: group.suggestedPriority,
setState: (newState: unknown) => {
props.updateAll(
datasourceId,
newState,
activeVisualization.setDimension({
layerId,
groupId: group.groupId,
columnId: newId,
prevState: props.visualizationState,
})
);
setPopoverState({
isOpen: true,
openId: newId,
addingToGroupId: null, // clear now that dimension exists
});
},
}}
/>
)}
</DragDrop>
) : null}
</>
</EuiFormRow>
);
})}
<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?.blur) {
el.blur();
}
onRemoveLayer();
}}
>
{isOnlyLayer
? i18n.translate('xpack.lens.resetLayer', {
defaultMessage: 'Reset layer',
})
: i18n.translate('xpack.lens.deleteLayer', {
defaultMessage: 'Delete layer',
})}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</ChildDragDropProvider>
);
}
function LayerSettings({
layerId,
activeVisualization,
layerConfigProps,
}: {
layerId: string;
activeVisualization: Visualization;
layerConfigProps: VisualizationLayerWidgetProps;
}) {
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

@ -16,7 +16,7 @@ import {
} from '../../types';
import { reducer, getInitialState } from './state_management';
import { DataPanelWrapper } from './data_panel_wrapper';
import { ConfigPanelWrapper } from './config_panel_wrapper';
import { ConfigPanelWrapper } from './config_panel';
import { FrameLayout } from './frame_layout';
import { SuggestionPanel } from './suggestion_panel';
import { WorkspacePanel } from './workspace_panel';

View file

@ -1,8 +1,6 @@
@import 'chart_switch';
@import 'config_panel_wrapper';
@import 'config_panel/index';
@import 'data_panel_wrapper';
@import 'expression_renderer';
@import 'frame_layout';
@import 'suggestion_panel';
@import 'workspace_panel_wrapper';

View file

@ -192,7 +192,7 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens
return (
<EuiLink
id={columnId}
className="lnsConfigPanel__triggerLink"
className="lnsLayerPanel__triggerLink"
onClick={() => {
props.togglePopover();
}}

View file

@ -273,7 +273,7 @@ export type VisualizationLayerWidgetProps<T = unknown> = VisualizationConfigProp
setState: (newState: T) => void;
};
type VisualizationDimensionGroupConfig = SharedDimensionProps & {
export type VisualizationDimensionGroupConfig = SharedDimensionProps & {
groupLabel: string;
/** ID is passed back to visualization. For example, `x` */
@ -368,55 +368,86 @@ export interface VisualizationType {
}
export interface Visualization<T = unknown, P = unknown> {
/** Plugin ID, such as "lnsXY" */
id: string;
visualizationTypes: VisualizationType[];
/**
* Initialize is allowed to modify the state stored in memory. The initialize function
* is called with a previous state in two cases:
* - Loadingn from a saved visualization
* - When using suggestions, the suggested state is passed in
*/
initialize: (frame: FramePublicAPI, state?: P) => T;
/**
* Can remove any state that should not be persisted to saved object, such as UI state
*/
getPersistableState: (state: T) => P;
/**
* Visualizations must provide at least one type for the chart switcher,
* but can register multiple subtypes
*/
visualizationTypes: VisualizationType[];
/**
* If the visualization has subtypes, update the subtype in state.
*/
switchVisualizationType?: (visualizationTypeId: string, state: T) => T;
/** Description is displayed as the clickable text in the chart switcher */
getDescription: (state: T) => { icon?: IconType; label: string };
/** Frame needs to know which layers the visualization is currently using */
getLayerIds: (state: T) => string[];
/** Reset button on each layer triggers this */
clearLayer: (state: T, layerId: string) => T;
/** Optional, if the visualization supports multiple layers */
removeLayer?: (state: T, layerId: string) => T;
/** Track added layers in internal state */
appendLayer?: (state: T, layerId: string) => T;
// Layer context menu is used by visualizations for styling the entire layer
// For example, the XY visualization uses this to have multiple chart types
getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined;
renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps<T>) => void;
/**
* For consistency across different visualizations, the dimension configuration UI is standardized
*/
getConfiguration: (
props: VisualizationConfigProps<T>
) => { groups: VisualizationDimensionGroupConfig[] };
getDescription: (
state: T
) => {
icon?: IconType;
label: string;
};
switchVisualizationType?: (visualizationTypeId: string, state: T) => T;
// For initializing from saved object
initialize: (frame: FramePublicAPI, state?: P) => T;
getPersistableState: (state: T) => P;
// Actions triggered by the frame which tell the datasource that a dimension is being changed
setDimension: (
props: VisualizationDimensionChangeProps<T> & {
groupId: string;
}
) => T;
removeDimension: (props: VisualizationDimensionChangeProps<T>) => T;
toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null;
/**
* Popover contents that open when the user clicks the contextMenuIcon. This can be used
* for extra configurability, such as for styling the legend or axis
*/
renderLayerContextMenu?: (domElement: Element, props: VisualizationLayerWidgetProps<T>) => void;
/**
* Visualizations can provide a custom icon which will open a layer-specific popover
* If no icon is provided, gear icon is default
*/
getLayerContextMenuIcon?: (opts: { state: T; layerId: string }) => IconType | undefined;
/**
* Epression to render a preview version of the chart in very constraint space.
* The frame is telling the visualization to update or set a dimension based on user interaction
* groupId is coming from the groupId provided in getConfiguration
*/
setDimension: (props: VisualizationDimensionChangeProps<T> & { groupId: string }) => T;
/**
* The frame is telling the visualization to remove a dimension. The visualization needs to
* look at its internal state to determine which dimension is being affected.
*/
removeDimension: (props: VisualizationDimensionChangeProps<T>) => T;
/**
* The frame will call this function on all visualizations at different times. The
* main use cases where visualization suggestions are requested are:
* - When dragging a field
* - When opening the chart switcher
* If the state is provided when requesting suggestions, the visualization is active.
* Most visualizations will apply stricter filtering to suggestions when they are active,
* because suggestions have the potential to remove the users's work in progress.
*/
getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>;
toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null;
/**
* Expression to render a preview version of the chart in very constrained space.
* If there is no expression provided, the preview icon is used.
*/
toPreviewExpression?: (state: T, frame: FramePublicAPI) => Ast | string | null;
// The frame will call this function on all visualizations when the table changes, or when
// rendering additional ways of using the data
getSuggestions: (context: SuggestionRequest<T>) => Array<VisualizationSuggestion<T>>;
}