diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss similarity index 100% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/_chart_switch.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_config_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_config_panel.scss new file mode 100644 index 0000000000000..1965b51f97034 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_config_panel.scss @@ -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; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss new file mode 100644 index 0000000000000..254807d06d386 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_dimension_popover.scss @@ -0,0 +1,9 @@ +.lnsDimensionPopover { + line-height: 0; + flex-grow: 1; +} + +.lnsDimensionPopover__trigger { + max-width: 100%; + display: block; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss new file mode 100644 index 0000000000000..8f09a358dd5e4 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss @@ -0,0 +1,4 @@ +@import 'chart_switch'; +@import 'config_panel'; +@import 'dimension_popover'; +@import 'layer_panel'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss similarity index 52% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss index 62a7f6b023f31..3fbc42f9a25a0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_config_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss @@ -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; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx similarity index 98% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx index 87c359eb1345b..3c61d270b1bcf 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx @@ -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 { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx similarity index 98% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx index 6bf7983d32023..1461449f3c1c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx @@ -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; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx new file mode 100644 index 0000000000000..e5d3e93258c0a --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -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 ( + <> + + {activeVisualization && visualizationState && ( + + )} + + ); +}); + +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 ( + + {layerIds.map(layerId => ( + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: state => + removeLayer({ + activeVisualization, + layerId, + trackUiEvent, + datasourceMap, + state, + }), + }); + }} + /> + ))} + {activeVisualization.appendLayer && visualizationState && ( + + + { + dispatch({ + type: 'UPDATE_STATE', + subType: 'ADD_LAYER', + updater: state => + appendLayer({ + activeVisualization, + generateId, + trackUiEvent, + activeDatasource: datasourceMap[activeDatasourceId], + state, + }), + }); + }} + iconType="plusInCircleFilled" + /> + + + )} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx new file mode 100644 index 0000000000000..36db13b74ac4f --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx @@ -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 ( + { + setPopoverState({ isOpen: false, openId: null, addingToGroupId: null }); + }} + button={trigger} + anchorPosition="leftUp" + withTitle + panelPaddingSize="s" + > + {panel} + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/index.ts new file mode 100644 index 0000000000000..754b3fb5c6fde --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/index.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts similarity index 100% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.test.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.test.ts diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts similarity index 95% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts index cc2cbb172d23e..3d1d590664238 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/layer_actions.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions.ts @@ -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; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx new file mode 100644 index 0000000000000..f7be82dd34ba3 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -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 & { + frame: FramePublicAPI; + layerId: string; + isOnlyLayer: boolean; + activeVisualization: Visualization; + visualizationState: unknown; + updateVisualization: StateSetter; + 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: false, + openId: null, + addingToGroupId: null, + }); + + const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); + const isEmptyLayer = !groups.some(d => d.accessors.length > 0); + + return ( + + + + + + + + {layerDatasource && ( + + { + 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); + }, + }} + /> + + )} + + + + + {groups.map((group, index) => { + const newId = generateId(); + const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + return ( + + <> + {group.accessors.map(accessor => ( + { + layerDatasource.onDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId: accessor, + filterOperations: group.filterOperations, + }); + }} + > + { + if (popoverState.isOpen) { + setPopoverState({ + isOpen: false, + openId: null, + addingToGroupId: null, + }); + } else { + setPopoverState({ + isOpen: true, + openId: accessor, + addingToGroupId: null, // not set for existing dimension + }); + } + }, + }} + /> + } + panel={ + + } + /> + + { + trackUiEvent('indexpattern_dimension_removed'); + props.updateAll( + datasourceId, + layerDatasource.removeColumn({ + layerId, + columnId: accessor, + prevState: layerDatasourceState, + }), + props.activeVisualization.removeDimension({ + layerId, + columnId: accessor, + prevState: props.visualizationState, + }) + ); + }} + /> + + ))} + {group.supportsMoreColumns ? ( + { + 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, + }) + ); + } + }} + > + + { + if (popoverState.isOpen) { + setPopoverState({ + isOpen: false, + openId: null, + addingToGroupId: null, + }); + } else { + setPopoverState({ + isOpen: true, + openId: newId, + addingToGroupId: group.groupId, + }); + } + }} + size="xs" + > + + + + } + panel={ + { + 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 + }); + }, + }} + /> + } + /> + + ) : null} + + + ); + })} + + + + + + { + // 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', + })} + + + + + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx new file mode 100644 index 0000000000000..57588e31590b4 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_settings.tsx @@ -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 ( + setIsOpen(!isOpen)} + data-test-subj="lns_layer_settings" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="leftUp" + > + + + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts new file mode 100644 index 0000000000000..df510d3648f8c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -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; + activeVisualizationId: string | null; + dispatch: (action: Action) => void; + framePublicAPI: FramePublicAPI; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + core: DatasourceDimensionEditorProps['core']; +} + +export interface DimensionPopoverState { + isOpen: boolean; + openId: string | null; + addingToGroupId: string | null; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx deleted file mode 100644 index da812e948b23f..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel_wrapper.tsx +++ /dev/null @@ -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; - activeVisualizationId: string | null; - dispatch: (action: Action) => void; - framePublicAPI: FramePublicAPI; - datasourceMap: Record; - 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 ( - <> - - {activeVisualization && visualizationState && ( - - )} - - ); -}); - -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 ( - - {layerIds.map(layerId => ( - { - dispatch({ - type: 'UPDATE_STATE', - subType: 'REMOVE_OR_CLEAR_LAYER', - updater: state => - removeLayer({ - activeVisualization, - layerId, - trackUiEvent, - datasourceMap, - state, - }), - }); - }} - /> - ))} - {activeVisualization.appendLayer && ( - - - { - dispatch({ - type: 'UPDATE_STATE', - subType: 'ADD_LAYER', - updater: state => - appendLayer({ - activeVisualization, - generateId, - trackUiEvent, - activeDatasource: datasourceMap[activeDatasourceId], - state, - }), - }); - }} - iconType="plusInCircleFilled" - /> - - - )} - - ); -} - -function LayerPanel( - props: Exclude & { - frame: FramePublicAPI; - layerId: string; - isOnlyLayer: boolean; - activeVisualization: Visualization; - visualizationState: unknown; - updateVisualization: StateSetter; - 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 ( - { - setPopoverState({ isOpen: false, openId: null, addingToGroupId: null }); - }} - button={trigger} - anchorPosition="leftUp" - withTitle - panelPaddingSize="s" - > - {panel} - - ); - } - - return ( - - - - - - - - {layerDatasource && ( - - { - 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); - }, - }} - /> - - )} - - - - - {groups.map((group, index) => { - const newId = generateId(); - const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; - return ( - - <> - {group.accessors.map(accessor => ( - { - layerDatasource.onDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId: accessor, - filterOperations: group.filterOperations, - }); - }} - > - {wrapInPopover( - accessor, - group.groupId, - { - if (popoverState.isOpen) { - setPopoverState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - } else { - setPopoverState({ - isOpen: true, - openId: accessor, - addingToGroupId: null, // not set for existing dimension - }); - } - }, - }} - />, - - )} - - { - trackUiEvent('indexpattern_dimension_removed'); - props.updateAll( - datasourceId, - layerDatasource.removeColumn({ - layerId, - columnId: accessor, - prevState: layerDatasourceState, - }), - props.activeVisualization.removeDimension({ - layerId, - columnId: accessor, - prevState: props.visualizationState, - }) - ); - }} - /> - - ))} - {group.supportsMoreColumns ? ( - { - 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, -
- { - if (popoverState.isOpen) { - setPopoverState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - } else { - setPopoverState({ - isOpen: true, - openId: newId, - addingToGroupId: group.groupId, - }); - } - }} - size="xs" - > - - -
, - { - 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 - }); - }, - }} - /> - )} -
- ) : null} - -
- ); - })} - - - - - - { - // 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', - })} - - - -
-
- ); -} - -function LayerSettings({ - layerId, - activeVisualization, - layerConfigProps, -}: { - layerId: string; - activeVisualization: Visualization; - layerConfigProps: VisualizationLayerWidgetProps; -}) { - const [isOpen, setIsOpen] = useState(false); - - if (!activeVisualization.renderLayerContextMenu) { - return null; - } - - return ( - setIsOpen(!isOpen)} - data-test-subj="lns_layer_settings" - /> - } - isOpen={isOpen} - closePopover={() => setIsOpen(false)} - anchorPosition="leftUp" - > - - - ); -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index b72d9081bbc91..5cd803e7cebbc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -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'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss index d4b27c6c98b3c..5e3726c953f11 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.scss @@ -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'; - diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index b3bd08d3bbfbe..583832aafcbe8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -192,7 +192,7 @@ export const IndexPatternDimensionTriggerComponent = function IndexPatternDimens return ( { props.togglePopover(); }} diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 0b432c0c70727..181f192520d0d 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -273,7 +273,7 @@ export type VisualizationLayerWidgetProps = 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 { + /** Plugin ID, such as "lnsXY" */ id: string; + /** + * 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) => void; - + /** + * For consistency across different visualizations, the dimension configuration UI is standardized + */ getConfiguration: ( props: VisualizationConfigProps ) => { 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; + /** + * 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) => 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; - // Actions triggered by the frame which tell the datasource that a dimension is being changed - setDimension: ( - props: VisualizationDimensionChangeProps & { - groupId: string; - } - ) => T; + /** + * 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 & { 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; - toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; + /** + * 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) => Array>; + toExpression: (state: T, frame: FramePublicAPI) => Ast | string | null; /** - * Epression to render a preview version of the chart in very constraint space. + * 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) => Array>; }