[Textbased] Lens integration - move updateAll callback to middleware (#162165)

## Summary

There are 2 things refactored in this PR:
1. To make the updates from the config panel update the chart in
discover, we have to run the `onUpdateCb` function in all places where
the state changes in Lens. The problem is that when user adds a new
feature to Lens, this is a potential source of sync bugs. We cannot test
this behaviour with the way it's written now to avoid these bugs. My
approach here changes the updates to a running a custom middleware every
time the store state updates. I had to exclude some initialization
actions to not end up in infinite loop updates (there's probably a
better approach instead of excluding I haven't thought of yet). Another
argument to do it this way is a performance improvement inside Lens
component where we had to sometimes get all the store to make an
`onUpdateCb` call.
2. the `useChartConfigPanel` hook should not really be a hook but a
component as it is a component (returns JSX.Element, displays UI based
on props) so I refactored it to `ChartConfigPanel`.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Marta Bondyra 2023-07-25 12:49:26 +02:00 committed by GitHub
parent 707ed134be
commit 95702ac644
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 350 additions and 240 deletions

View file

@ -33,7 +33,9 @@ export const unifiedHistogramServicesMock = {
suggestions: jest.fn(() => allSuggestionsMock),
};
}),
EditLensConfigPanelApi: jest.fn().mockResolvedValue(<span>Lens Config Panel Component</span>),
EditLensConfigPanelApi: jest
.fn()
.mockResolvedValue(() => <span>Lens Config Panel Component</span>),
},
storage: {
get: jest.fn(),

View file

@ -42,7 +42,7 @@ import { useTotalHits } from './hooks/use_total_hits';
import { useRequestParams } from './hooks/use_request_params';
import { useChartStyles } from './hooks/use_chart_styles';
import { useChartActions } from './hooks/use_chart_actions';
import { useChartConfigPanel } from './hooks/use_chart_config_panel';
import { ChartConfigPanel } from './chart_config_panel';
import { getLensAttributes } from './utils/get_lens_attributes';
import { useRefetch } from './hooks/use_refetch';
import { useEditVisualization } from './hooks/use_edit_visualization';
@ -220,19 +220,6 @@ export function Chart({
]
);
const ChartConfigPanel = useChartConfigPanel({
services,
lensAttributesContext,
dataView,
lensTablesAdapter,
currentSuggestion,
isFlyoutVisible,
setIsFlyoutVisible,
isPlainRecord,
query: originalQuery,
onSuggestionChange,
});
const onSuggestionSelectorChange = useCallback(
(s: Suggestion | undefined) => {
onSuggestionChange?.(s);
@ -455,7 +442,22 @@ export function Chart({
isSaveable={false}
/>
)}
{isFlyoutVisible && ChartConfigPanel}
{isFlyoutVisible && (
<ChartConfigPanel
{...{
services,
lensAttributesContext,
dataView,
lensTablesAdapter,
currentSuggestion,
isFlyoutVisible,
setIsFlyoutVisible,
isPlainRecord,
query: originalQuery,
onSuggestionChange,
}}
/>
)}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { setTimeout } from 'timers/promises';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { lensTablesAdapterMock } from '../__mocks__/lens_table_adapter';
import { ChartConfigPanel } from './chart_config_panel';
import type { LensAttributesContext } from './utils/get_lens_attributes';
describe('ChartConfigPanel', () => {
it('should return a jsx element to edit the visualization', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const { container } = render(
<ChartConfigPanel
{...{
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: true,
lensTablesAdapter: lensTablesAdapterMock,
query: {
sql: 'Select * from test',
},
}}
/>
);
await act(() => setTimeout(0));
expect(container).not.toBeEmptyDOMElement();
});
it('should return null if not in text based mode', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const { container } = render(
<ChartConfigPanel
{...{
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: false,
}}
/>
);
await act(() => setTimeout(0));
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -12,10 +12,10 @@ import type { Suggestion } from '@kbn/lens-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/common';
import type { UnifiedHistogramServices } from '../../types';
import type { LensAttributesContext } from '../utils/get_lens_attributes';
import type { UnifiedHistogramServices } from '../types';
import type { LensAttributesContext } from './utils/get_lens_attributes';
export function useChartConfigPanel({
export function ChartConfigPanel({
services,
lensAttributesContext,
dataView,
@ -49,7 +49,9 @@ export function useChartConfigPanel({
...(datasourceState && { datasourceState }),
...(visualizationState && { visualizationState }),
} as Suggestion;
onSuggestionChange?.(updatedSuggestion);
if (!isEqual(updatedSuggestion, currentSuggestion)) {
onSuggestionChange?.(updatedSuggestion);
}
},
[currentSuggestion, onSuggestionChange]
);

View file

@ -1,66 +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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { renderHook } from '@testing-library/react-hooks';
import { act } from 'react-test-renderer';
import { setTimeout } from 'timers/promises';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { unifiedHistogramServicesMock } from '../../__mocks__/services';
import { lensTablesAdapterMock } from '../../__mocks__/lens_table_adapter';
import { useChartConfigPanel } from './use_chart_config_panel';
import type { LensAttributesContext } from '../utils/get_lens_attributes';
describe('useChartConfigPanel', () => {
it('should return a jsx element to edit the visualization', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const hook = renderHook(() =>
useChartConfigPanel({
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: true,
lensTablesAdapter: lensTablesAdapterMock,
query: {
sql: 'Select * from test',
},
})
);
await act(() => setTimeout(0));
expect(hook.result.current).toBeDefined();
expect(hook.result.current).not.toBeNull();
});
it('should return null if not in text based mode', async () => {
const lensAttributes = {
visualizationType: 'lnsXY',
title: 'test',
} as TypedLensByValueInput['attributes'];
const hook = renderHook(() =>
useChartConfigPanel({
services: unifiedHistogramServicesMock,
dataView: dataViewWithTimefieldMock,
lensAttributesContext: {
attributes: lensAttributes,
} as unknown as LensAttributesContext,
isFlyoutVisible: true,
setIsFlyoutVisible: jest.fn(),
isPlainRecord: false,
})
);
await act(() => setTimeout(0));
expect(hook.result.current).toBeNull();
});
});

View file

@ -26,7 +26,6 @@ import {
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
selectSavedObjectFormat,
updateIndexPatterns,
updateDatasourceState,
@ -99,7 +98,7 @@ export function App({
const saveAndExit = useRef<() => void>();
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = useCallback(
const dispatchSetState = useCallback(
(state: Partial<LensAppState>) => dispatch(setState(state)),
[dispatch]
);

View file

@ -25,7 +25,6 @@ import {
useLensSelector,
useLensDispatch,
LensAppState,
DispatchSetState,
switchAndCleanDatasource,
} from '../state_management';
import {
@ -314,7 +313,7 @@ export const LensTopNavMenu = ({
} = useLensSelector((state) => state.lens);
const dispatch = useLensDispatch();
const dispatchSetState: DispatchSetState = React.useCallback(
const dispatchSetState = React.useCallback(
(state: Partial<LensAppState>) => dispatch(setState(state)),
[dispatch]
);

View file

@ -6,7 +6,6 @@
*/
import React, { FC, useCallback, useEffect, useState, useMemo } from 'react';
import { PreloadedState } from '@reduxjs/toolkit';
import { AppMountParameters, CoreSetup, CoreStart } from '@kbn/core/public';
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { RouteComponentProps } from 'react-router-dom';
@ -48,10 +47,9 @@ import {
navigateAway,
LensRootStore,
loadInitial,
LensAppState,
LensState,
setState,
} from '../state_management';
import { getPreloadedState, setState } from '../state_management/lens_slice';
import { getPreloadedState } from '../state_management/lens_slice';
import { getLensInspectorService } from '../lens_inspector_service';
import {
LensAppLocator,
@ -276,9 +274,7 @@ export async function mountApp(
initialContext,
initialStateFromLocator,
};
const lensStore: LensRootStore = makeConfigureStore(storeDeps, {
lens: getPreloadedState(storeDeps) as LensAppState,
} as unknown as PreloadedState<LensState>);
const lensStore: LensRootStore = makeConfigureStore(storeDeps);
const EditorRenderer = React.memo(
(props: { id?: string; history: History<unknown>; editByValue?: boolean }) => {
@ -322,7 +318,7 @@ export async function mountApp(
data.query.filterManager.setAppFilters([]);
data.query.queryString.clearQuery();
}
lensStore.dispatch(setState(getPreloadedState(storeDeps) as LensAppState));
lensStore.dispatch(setState(getPreloadedState(storeDeps)));
lensStore.dispatch(loadInitial({ redirectCallback, initialInput, history: props.history }));
}, [initialInput, props.history, redirectCallback]);
useEffect(() => {

View file

@ -5,30 +5,23 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import { EuiFlyout, EuiLoadingSpinner, EuiOverlayMask } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Provider } from 'react-redux';
import { PreloadedState } from '@reduxjs/toolkit';
import { MiddlewareAPI, Dispatch, Action } from '@reduxjs/toolkit';
import { css } from '@emotion/react';
import type { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { isEqual } from 'lodash';
import type { LensPluginStartDependencies } from '../../../plugin';
import {
makeConfigureStore,
LensRootStore,
LensAppState,
LensState,
loadInitial,
} from '../../../state_management';
import { getPreloadedState } from '../../../state_management/lens_slice';
import { makeConfigureStore, LensRootStore, loadInitial } from '../../../state_management';
import { generateId } from '../../../id_generator';
import type { DatasourceMap, VisualizationMap } from '../../../types';
import {
LensEditConfigurationFlyout,
type EditConfigPanelProps,
} from './lens_configuration_flyout';
import type { LensAppServices } from '../../types';
export type EditLensConfigurationProps = Omit<
EditConfigPanelProps,
@ -43,12 +36,39 @@ function LoadingSpinnerWithOverlay() {
);
}
export function getEditLensConfiguration(
type UpdaterType = (datasourceState: unknown, visualizationState: unknown) => void;
const updatingMiddleware =
(updater: UpdaterType) => (store: MiddlewareAPI) => (next: Dispatch) => (action: Action) => {
const {
datasourceStates: prevDatasourceStates,
visualization: prevVisualization,
activeDatasourceId: prevActiveDatasourceId,
} = store.getState().lens;
next(action);
const { datasourceStates, visualization, activeDatasourceId } = store.getState().lens;
if (
!isEqual(prevDatasourceStates, datasourceStates) ||
!isEqual(prevVisualization, visualization) ||
prevActiveDatasourceId !== activeDatasourceId
) {
updater(datasourceStates[activeDatasourceId].state, visualization.state);
}
};
export async function getEditLensConfiguration(
coreStart: CoreStart,
startDependencies: LensPluginStartDependencies,
visualizationMap?: VisualizationMap,
datasourceMap?: DatasourceMap
) {
const { getLensServices, getLensAttributeService } = await import('../../../async_services');
const lensServices = await getLensServices(
coreStart,
startDependencies,
getLensAttributeService(coreStart, startDependencies)
);
return ({
attributes,
dataView,
@ -59,23 +79,6 @@ export function getEditLensConfiguration(
adaptersTables,
panelId,
}: EditLensConfigurationProps) => {
const [lensServices, setLensServices] = useState<LensAppServices>();
useEffect(() => {
async function loadLensService() {
const { getLensServices, getLensAttributeService } = await import(
'../../../async_services'
);
const lensServicesT = await getLensServices(
coreStart,
startDependencies,
getLensAttributeService(coreStart, startDependencies)
);
setLensServices(lensServicesT);
}
loadLensService();
}, []);
if (!lensServices || !datasourceMap || !visualizationMap || !dataView.id) {
return <LoadingSpinnerWithOverlay />;
}
@ -89,9 +92,11 @@ export function getEditLensConfiguration(
? datasourceState.initialContext
: undefined,
};
const lensStore: LensRootStore = makeConfigureStore(storeDeps, {
lens: getPreloadedState(storeDeps) as LensAppState,
} as unknown as PreloadedState<LensState>);
const lensStore: LensRootStore = makeConfigureStore(
storeDeps,
undefined,
updatingMiddleware(updateAll)
);
lensStore.dispatch(
loadInitial({
initialInput: {

View file

@ -93,7 +93,6 @@ export function LensEditConfigurationFlyout({
dataViews: startDependencies.dataViews,
uiActions: startDependencies.uiActions,
hideLayerHeader: datasourceId === 'textBased',
onUpdateStateCb: updateAll,
};
return (
<>
@ -120,7 +119,6 @@ export function LensEditConfigurationFlyout({
<VisualizationToolbar
activeVisualization={activeVisualization}
framePublicAPI={framePublicAPI}
onUpdateStateCb={updateAll}
/>
<EuiSpacer size="m" />
<ConfigPanelWrapper {...layerPanelsProps} />

View file

@ -170,25 +170,18 @@ describe('ConfigPanel', () => {
it('updates datasources and visualizations', async () => {
const props = getDefaultProps();
const onUpdateCbSpy = jest.fn();
const newProps = {
...props,
onUpdateStateCb: onUpdateCbSpy,
};
const { instance, lensStore } = await prepareAndMountComponent(newProps);
const { instance, lensStore } = await prepareAndMountComponent(props);
const { updateDatasource, updateAll } = instance.find(LayerPanel).props();
const newDatasourceState = 'updated';
updateDatasource('testDatasource', newDatasourceState);
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(1);
expect(onUpdateCbSpy).toHaveBeenCalled();
expect((lensStore.dispatch as jest.Mock).mock.calls[0][0].payload.newDatasourceState).toEqual(
'updated'
);
updateAll('testDatasource', newDatasourceState, props.visualizationState);
expect(onUpdateCbSpy).toHaveBeenCalled();
// wait for one tick so async updater has a chance to trigger
await waitMs(0);
expect(lensStore.dispatch).toHaveBeenCalledTimes(3);

View file

@ -6,7 +6,6 @@
*/
import React, { useMemo, memo, useCallback } from 'react';
import { useStore } from 'react-redux';
import { EuiForm } from '@elastic/eui';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import { isOfAggregateQueryType } from '@kbn/es-query';
@ -14,7 +13,7 @@ import {
UPDATE_FILTER_REFERENCES_ACTION,
UPDATE_FILTER_REFERENCES_TRIGGER,
} from '@kbn/unified-search-plugin/public';
import { isEqual } from 'lodash';
import { DragDropIdentifier, DropType } from '@kbn/dom-drag-drop';
import {
changeIndexPattern,
@ -58,8 +57,7 @@ export function LayerPanels(
activeVisualization: Visualization;
}
) {
const lensStore = useStore();
const { activeVisualization, datasourceMap, indexPatternService, onUpdateStateCb } = props;
const { activeVisualization, datasourceMap, indexPatternService } = props;
const { activeDatasourceId, visualization, datasourceStates, query } = useLensSelector(
(state) => state.lens
);
@ -81,31 +79,19 @@ export function LayerPanels(
newState,
})
);
if (onUpdateStateCb && activeDatasourceId) {
const dsState = datasourceStates[activeDatasourceId].state;
onUpdateStateCb?.(dsState, newState);
}
},
[activeDatasourceId, activeVisualization.id, datasourceStates, dispatchLens, onUpdateStateCb]
[activeVisualization.id, dispatchLens]
);
const updateDatasource = useMemo(
() =>
(datasourceId: string | undefined, newState: unknown, dontSyncLinkedDimensions?: boolean) => {
if (datasourceId) {
const newDatasourceState =
typeof newState === 'function'
? newState(datasourceStates[datasourceId].state)
: newState;
if (isEqual(newDatasourceState, datasourceStates[datasourceId].state)) {
return;
}
onUpdateStateCb?.(newDatasourceState, visualization.state);
dispatchLens(
updateDatasourceState({
newDatasourceState,
newDatasourceState:
typeof newState === 'function'
? newState(datasourceStates[datasourceId].state)
: newState,
datasourceId,
clearStagedPreview: false,
dontSyncLinkedDimensions,
@ -113,7 +99,7 @@ export function LayerPanels(
);
}
},
[dispatchLens, onUpdateStateCb, visualization.state, datasourceStates]
[dispatchLens, datasourceStates]
);
const updateDatasourceAsync = useMemo(
() => (datasourceId: string | undefined, newState: unknown) => {
@ -148,8 +134,6 @@ export function LayerPanels(
? newVisualizationState(visualization.state)
: newVisualizationState;
onUpdateStateCb?.(newDsState, newVisState);
dispatchLens(
updateVisualizationState({
visualizationId: activeVisualization.id,
@ -166,7 +150,7 @@ export function LayerPanels(
);
}, 0);
},
[dispatchLens, onUpdateStateCb, visualization.state, datasourceStates, activeVisualization.id]
[dispatchLens, visualization.state, datasourceStates, activeVisualization.id]
);
const toggleFullscreen = useCallback(() => {
@ -207,24 +191,15 @@ export function LayerPanels(
layerIds,
})
);
if (activeDatasourceId && onUpdateStateCb) {
const newState = lensStore.getState().lens;
onUpdateStateCb(
newState.datasourceStates[activeDatasourceId].state,
newState.visualization.state
);
}
removeLayerRef(layerToRemoveId);
},
[
activeDatasourceId,
activeVisualization.id,
datasourceMap,
datasourceStates,
dispatchLens,
layerIds,
lensStore,
onUpdateStateCb,
props.framePublicAPI.datasourceLayers,
props.uiActions,
removeLayerRef,
@ -267,13 +242,6 @@ export function LayerPanels(
dispatchLens(addLayerAction({ layerId, layerType, extraArg, ignoreInitialValues }));
if (activeDatasourceId && onUpdateStateCb) {
const newState = lensStore.getState().lens;
onUpdateStateCb(
newState.datasourceStates[activeDatasourceId].state,
newState.visualization.state
);
}
setNextFocusedLayerId(layerId);
};
@ -355,13 +323,6 @@ export function LayerPanels(
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId];
const datasourceId = datasourcePublicAPI?.datasourceId;
dispatchLens(removeDimension({ ...dimensionProps, datasourceId }));
if (datasourceId && onUpdateStateCb) {
const newState = lensStore.getState().lens;
onUpdateStateCb(
newState.datasourceStates[datasourceId].state,
newState.visualization.state
);
}
}}
toggleFullscreen={toggleFullscreen}
indexPatternService={indexPatternService}

View file

@ -29,7 +29,6 @@ export interface ConfigPanelWrapperProps {
uiActions: UiActionsStart;
getUserMessages?: UserMessagesGetter;
hideLayerHeader?: boolean;
onUpdateStateCb?: (datasourceState: unknown, visualizationState: unknown) => void;
}
export interface LayerPanelProps {

View file

@ -383,15 +383,16 @@ describe('editor_frame', () => {
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
mockVisualization.getConfiguration.mockClear();
const setDatasourceState = (mockDatasource.DataPanelComponent as jest.Mock).mock.calls[0][0]
.setState;
act(() => {
setDatasourceState({});
setDatasourceState('newState');
});
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(3);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(1);
expect(mockVisualization.getConfiguration).toHaveBeenCalledWith(
expect.objectContaining({
frame: expect.objectContaining({
datasourceLayers: {

View file

@ -798,7 +798,7 @@ describe('workspace_panel', () => {
lensStore.dispatch(
updateDatasourceState({
datasourceId: 'testDatasource',
newDatasourceState: {},
newDatasourceState: 'newState',
})
);
});

View file

@ -30,6 +30,7 @@ import {
selectChangesApplied,
applyChanges,
selectAutoApplyEnabled,
selectVisualizationState,
} from '../../../state_management';
import { WorkspaceTitle } from './title';
import { LensInspector } from '../../../lens_inspector_service';
@ -52,13 +53,10 @@ export interface WorkspacePanelWrapperProps {
export function VisualizationToolbar(props: {
activeVisualization: Visualization | null;
framePublicAPI: FramePublicAPI;
onUpdateStateCb?: (datasourceState: unknown, visualizationState: unknown) => void;
}) {
const dispatchLens = useLensDispatch();
const { activeDatasourceId, visualization, datasourceStates } = useLensSelector(
(state) => state.lens
);
const { activeVisualization, onUpdateStateCb } = props;
const visualization = useLensSelector(selectVisualizationState);
const { activeVisualization } = props;
const setVisualizationState = useCallback(
(newState: unknown) => {
if (!activeVisualization) {
@ -70,12 +68,8 @@ export function VisualizationToolbar(props: {
newState,
})
);
if (activeDatasourceId && onUpdateStateCb) {
const dsState = datasourceStates[activeDatasourceId].state;
onUpdateStateCb?.(dsState, newState);
}
},
[activeDatasourceId, datasourceStates, dispatchLens, activeVisualization, onUpdateStateCb]
[dispatchLens, activeVisualization]
);
const ToolbarComponent = props.activeVisualization?.ToolbarComponent;

View file

@ -780,7 +780,7 @@ export class Embeddable
async openConfingPanel(startDependencies: LensPluginStartDependencies) {
const { getEditLensConfiguration } = await import('../async_services');
const Component = getEditLensConfiguration(
const Component = await getEditLensConfiguration(
this.deps.coreStart,
startDependencies,
this.deps.visualizationMap,

View file

@ -21,7 +21,9 @@ export const lensPluginMock = {
SaveModalComponent: jest.fn(() => {
return <span>Lens Save Modal Component</span>;
}),
EditLensConfigPanelApi: jest.fn().mockResolvedValue(<span>Lens Config Panel Component</span>),
EditLensConfigPanelApi: jest
.fn()
.mockResolvedValue(() => <span>Lens Config Panel Component</span>),
canUseEditor: jest.fn(() => true),
navigateToPrefilledEditor: jest.fn(),
getXyVisTypes: jest

View file

@ -677,7 +677,13 @@ export class LensPlugin {
this.editorFrameService!.loadVisualizations(),
this.editorFrameService!.loadDatasources(),
]);
return getEditLensConfiguration(core, startDependencies, visualizationMap, datasourceMap);
const Component = await getEditLensConfiguration(
core,
startDependencies,
visualizationMap,
datasourceMap
);
return Component;
},
};
}

View file

@ -57,7 +57,7 @@ describe('contextMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'lens/setState',
type: 'lens/setExecutionContext',
payload: {
visualization: {
state: {},
@ -74,7 +74,7 @@ describe('contextMiddleware', () => {
},
searchSessionId: 'sessionId-1',
},
type: 'lens/setState',
type: 'lens/setExecutionContext',
});
expect(next).toHaveBeenCalledWith(action);
});
@ -92,7 +92,7 @@ describe('contextMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'lens/setState',
type: 'lens/setExecutionContext',
payload: {
visualization: {
state: {},
@ -109,7 +109,7 @@ describe('contextMiddleware', () => {
},
searchSessionId: 'sessionId-1',
},
type: 'lens/setState',
type: 'lens/setExecutionContext',
});
expect(next).toHaveBeenCalledWith(action);
});
@ -136,7 +136,7 @@ describe('contextMiddleware', () => {
// setState shouldn't trigger
const setStateAction = {
type: 'lens/setState',
type: 'lens/setExecutionContext',
payload: {
visualization: {
state: {},
@ -146,14 +146,14 @@ describe('contextMiddleware', () => {
};
invoke(setStateAction);
expect(store.dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'lens/setState' })
expect.objectContaining({ type: 'lens/setExecutionContext' })
);
// applyChanges should trigger
const applyChangesAction = applyChanges();
invoke(applyChangesAction);
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: 'lens/setState' })
expect.objectContaining({ type: 'lens/setExecutionContext' })
);
});
});
@ -171,7 +171,7 @@ describe('contextMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'lens/setState',
type: 'lens/setExecutionContext',
payload: {
visualization: {
state: {},
@ -196,7 +196,7 @@ describe('contextMiddleware', () => {
});
const { next, invoke, store } = createMiddleware(data);
const action = {
type: 'lens/setState',
type: 'lens/setExecutionContext',
payload: {
visualization: {
state: {},

View file

@ -9,7 +9,7 @@ import { Dispatch, MiddlewareAPI, PayloadAction } from '@reduxjs/toolkit';
import moment from 'moment';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import {
setState,
setExecutionContext,
LensDispatch,
LensStoreDeps,
navigateAway,
@ -93,7 +93,7 @@ function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) {
// if the lag is significant, start a new session to clear the cache
if (nowDiff > Math.max(timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT, TIME_LAG_MIN_LIMIT)) {
dispatch(
setState({
setExecutionContext({
searchSessionId: data.search.session.start(),
resolvedDateRange: getResolvedDateRange(timefilter),
})

View file

@ -8,7 +8,7 @@
import { delay, finalize, switchMap, tap } from 'rxjs/operators';
import { debounce, isEqual } from 'lodash';
import { waitUntilNextSessionCompletes$, DataPublicPluginStart } from '@kbn/data-plugin/public';
import { setState, LensGetState, LensDispatch } from '..';
import { setExecutionContext, LensGetState, LensDispatch } from '..';
import { getResolvedDateRange } from '../../utils';
/**
@ -21,14 +21,14 @@ export function subscribeToExternalContext(
) {
const { query: queryService, search } = data;
const { filterManager } = queryService;
const dispatchFromExternal = (searchSessionId = search.session.start()) => {
const globalFilters = filterManager.getFilters();
const filters = isEqual(getState().lens.filters, globalFilters)
? null
: { filters: globalFilters };
dispatch(
setState({
setExecutionContext({
searchSessionId,
...filters,
resolvedDateRange: getResolvedDateRange(queryService.timefilter.timefilter),

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Action, Dispatch, MiddlewareAPI } from '@reduxjs/toolkit';
import { makeConfigureStore, onActiveDataChange, setExecutionContext } from '.';
import { mockStoreDeps } from '../mocks';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import { Filter } from '@kbn/es-query';
describe('state management initialization and middlewares', () => {
let store: ReturnType<typeof makeConfigureStore>;
const updaterFn = jest.fn();
const customMiddleware = jest.fn(
(updater) => (_store: MiddlewareAPI) => (next: Dispatch) => (action: Action) => {
next(action);
updater(action);
}
);
beforeEach(() => {
store = makeConfigureStore(mockStoreDeps(), undefined, customMiddleware(updaterFn));
store.dispatch = jest.fn(store.dispatch);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('customMiddleware', () => {
test('customMiddleware is initialized on store creation', () => {
expect(customMiddleware).toHaveBeenCalled();
expect(updaterFn).not.toHaveBeenCalled();
});
test('customMiddleware is run on action dispatch', () => {
store.dispatch({ type: 'ANY_TYPE' });
expect(updaterFn).toHaveBeenCalledWith({ type: 'ANY_TYPE' });
});
});
describe('optimizingMiddleware', () => {
test('state is updating when the activeData changes', () => {
expect(store.getState().lens.activeData).toEqual(undefined);
store.dispatch(
onActiveDataChange({ activeData: { id: 1 } as unknown as TableInspectorAdapter })
);
expect(store.getState().lens.activeData).toEqual({ id: 1 });
// this is a bit convoluted - we are checking that the updaterFn has been called because it's called (as the next middleware)
// before the reducer function but we're actually interested in the reducer function being called that's further down the pipeline
expect(updaterFn).toHaveBeenCalledTimes(1);
store.dispatch(
onActiveDataChange({ activeData: { id: 2 } as unknown as TableInspectorAdapter })
);
expect(store.getState().lens.activeData).toEqual({ id: 2 });
expect(updaterFn).toHaveBeenCalledTimes(2);
});
test('state is not updating when the payload activeData is the same as in state', () => {
store.dispatch(
onActiveDataChange({ activeData: { id: 1 } as unknown as TableInspectorAdapter })
);
expect(store.getState().lens.activeData).toEqual({ id: 1 });
expect(updaterFn).toHaveBeenCalledTimes(1);
store.dispatch(
onActiveDataChange({ activeData: { id: 1 } as unknown as TableInspectorAdapter })
);
expect(store.getState().lens.activeData).toEqual({ id: 1 });
expect(updaterFn).toHaveBeenCalledTimes(1);
});
test('state is updating when the execution context changes', () => {
expect(store.getState().lens.filters).toEqual([]);
expect(store.getState().lens.query).toEqual({ language: 'lucene', query: '' });
expect(store.getState().lens.searchSessionId).toEqual('');
store.dispatch(
setExecutionContext({
filters: ['filter'] as unknown as Filter[],
query: { language: 'lucene', query: 'query' },
searchSessionId: 'searchSessionId',
})
);
expect(store.getState().lens.filters).toEqual(['filter']);
expect(store.getState().lens.query).toEqual({ language: 'lucene', query: 'query' });
expect(store.getState().lens.searchSessionId).toEqual('searchSessionId');
});
});
});

View file

@ -5,10 +5,17 @@
* 2.0.
*/
import { configureStore, getDefaultMiddleware, PreloadedState } from '@reduxjs/toolkit';
import {
configureStore,
getDefaultMiddleware,
PreloadedState,
Action,
Dispatch,
MiddlewareAPI,
} from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { makeLensReducer, lensActions } from './lens_slice';
import { makeLensReducer, lensActions, getPreloadedState } from './lens_slice';
import { LensState, LensStoreDeps } from './types';
import { initMiddleware } from './init_middleware';
import { optimizingMiddleware } from './optimizing_middleware';
@ -19,7 +26,10 @@ export * from './selectors';
export const {
loadInitial,
initEmpty,
initExisting,
navigateAway,
setExecutionContext,
setState,
enableAutoApply,
disableAutoApply,
@ -36,7 +46,6 @@ export const {
switchAndCleanDatasource,
updateIndexPatterns,
setToggleFullscreen,
initEmpty,
editVisualizationAction,
removeLayers,
removeOrClearLayer,
@ -50,9 +59,12 @@ export const {
changeIndexPattern,
} = lensActions;
type CustomMiddleware = (store: MiddlewareAPI) => (next: Dispatch) => (action: Action) => void;
export const makeConfigureStore = (
storeDeps: LensStoreDeps,
preloadedState: PreloadedState<LensState>
preloadedState?: PreloadedState<LensState> | undefined,
customMiddleware?: CustomMiddleware
) => {
const middleware = [
...getDefaultMiddleware({
@ -70,10 +82,13 @@ export const makeConfigureStore = (
},
}),
initMiddleware(storeDeps),
optimizingMiddleware(),
contextMiddleware(storeDeps),
fullscreenMiddleware(storeDeps),
optimizingMiddleware(),
];
if (customMiddleware) {
middleware.push(customMiddleware);
}
if (process.env.NODE_ENV === 'development') {
middleware.push(
createLogger({
@ -88,7 +103,9 @@ export const makeConfigureStore = (
lens: makeLensReducer(storeDeps),
},
middleware,
preloadedState,
preloadedState: preloadedState ?? {
lens: getPreloadedState(storeDeps),
},
});
};

View file

@ -9,7 +9,7 @@ import { cloneDeep } from 'lodash';
import { MiddlewareAPI } from '@reduxjs/toolkit';
import { i18n } from '@kbn/i18n';
import { History } from 'history';
import { setState, initEmpty, LensStoreDeps } from '..';
import { setState, initExisting, initEmpty, LensStoreDeps } from '..';
import { disableAutoApply, getPreloadedState } from '../lens_slice';
import { SharingSavedObjectProps } from '../../types';
import { LensEmbeddableInput, LensByReferenceInput } from '../../embeddable/embeddable';
@ -171,7 +171,7 @@ export function loadInitial(
const currentSessionId =
initialStateFromLocator?.searchSessionId || data.search.session.getSessionId();
store.dispatch(
setState({
initExisting({
isSaveable: true,
filters: initialStateFromLocator.filters || data.query.filterManager.getFilters(),
query: initialStateFromLocator.query || emptyState.query,
@ -331,7 +331,7 @@ export function loadInitial(
}) => {
const currentSessionId = data.search.session.getSessionId();
store.dispatch(
setState({
initExisting({
isSaveable: true,
sharingSavedObjectProps,
filters: data.query.filterManager.getFilters(),

View file

@ -8,7 +8,7 @@
import { createAction, createReducer, current, PayloadAction } from '@reduxjs/toolkit';
import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public';
import { mapValues, uniq } from 'lodash';
import { Query } from '@kbn/es-query';
import { Filter, Query } from '@kbn/es-query';
import { History } from 'history';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { EventAnnotationGroupConfig } from '@kbn/event-annotation-common';
@ -27,7 +27,7 @@ import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from
import type { DataViewsState, LensAppState, LensStoreDeps, VisualizationState } from './types';
import type { Datasource, Visualization } from '../types';
import { generateId } from '../id_generator';
import type { LayerType } from '../../common/types';
import type { DateRange, LayerType } from '../../common/types';
import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers';
import type { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types';
import { selectDataViews, selectFramePublicAPI } from './selectors';
@ -113,7 +113,7 @@ export const getPreloadedState = ({
? data.query.queryString.getDefaultQuery()
: getQueryFromContext(initialContext, data);
const state = {
const state: LensAppState = {
...initialState,
isLoading: true,
// Do not use app-specific filters from previous app,
@ -124,7 +124,7 @@ export const getPreloadedState = ({
: 'searchFilters' in initialContext && initialContext.searchFilters
? initialContext.searchFilters
: data.query.filterManager.getFilters(),
searchSessionId: data.search.session.getSessionId(),
searchSessionId: data.search.session.getSessionId() || '',
resolvedDateRange: getResolvedDateRange(data.query.timefilter.timefilter),
isLinkedToOriginatingApp: Boolean(
embeddableEditorIncomingState?.originatingApp ||
@ -140,7 +140,18 @@ export const getPreloadedState = ({
return state;
};
export interface SetExecutionContextPayload {
query?: Query;
filters?: Filter[];
searchSessionId?: string;
resolvedDateRange?: DateRange;
}
export const setState = createAction<Partial<LensAppState>>('lens/setState');
export const setExecutionContext = createAction<SetExecutionContextPayload>(
'lens/setExecutionContext'
);
export const initExisting = createAction<Partial<LensAppState>>('lens/initExisting');
export const onActiveDataChange = createAction<{
activeData: TableInspectorAdapter;
}>('lens/onActiveDataChange');
@ -268,7 +279,9 @@ export const registerLibraryAnnotationGroup = createAction<{
}>('lens/registerLibraryAnnotationGroup');
export const lensActions = {
initExisting,
setState,
setExecutionContext,
onActiveDataChange,
setSaveable,
enableAutoApply,
@ -312,6 +325,18 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
...payload,
};
},
[setExecutionContext.type]: (state, { payload }: PayloadAction<SetExecutionContextPayload>) => {
return {
...state,
...payload,
};
},
[initExisting.type]: (state, { payload }: PayloadAction<Partial<LensAppState>>) => {
return {
...state,
...payload,
};
},
[onActiveDataChange.type]: (
state,
{ payload: { activeData } }: PayloadAction<{ activeData: TableInspectorAdapter }>

View file

@ -7,7 +7,8 @@
import { Dispatch, MiddlewareAPI, Action } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { onActiveDataChange } from '.';
import { onActiveDataChange, updateDatasourceState, setExecutionContext } from '.';
import { SetExecutionContextPayload } from './lens_slice';
/** cancels updates to the store that don't change the state */
export const optimizingMiddleware = () => (store: MiddlewareAPI) => {
@ -16,7 +17,30 @@ export const optimizingMiddleware = () => (store: MiddlewareAPI) => {
if (isEqual(store.getState().lens.activeData, action.payload.activeData)) {
return;
}
} else if (updateDatasourceState.match(action)) {
const { datasourceId, newDatasourceState } = action.payload;
const { datasourceStates } = store.getState().lens;
if (isEqual(datasourceStates[datasourceId].state, newDatasourceState)) {
return;
}
} else if (setExecutionContext.match(action)) {
const payloadKeys = Object.keys(action.payload);
const prevState = store.getState().lens;
const stateSliceToUpdate = payloadKeys.reduce<SetExecutionContextPayload>(
(acc, currentKey) => {
return {
...acc,
[currentKey]: prevState[currentKey],
};
},
{}
);
if (isEqual(action.payload, stateSliceToUpdate)) {
return;
}
}
next(action);
};
};

View file

@ -67,11 +67,6 @@ export interface LensAppState extends EditorFrameState {
annotationGroups: AnnotationGroups;
}
export type DispatchSetState = (state: Partial<LensAppState>) => {
payload: Partial<LensAppState>;
type: string;
};
export interface LensState {
lens: LensAppState;
}