[Lens] Redux selectors optimization (#107559)

This commit is contained in:
Marta Bondyra 2021-08-10 21:06:35 +02:00 committed by GitHub
parent 4f7e62fff3
commit c5499c6592
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 874 additions and 1019 deletions

View file

@ -13,13 +13,7 @@ import { App } from './app';
import { LensAppProps, LensAppServices } from './types';
import { EditorFrameInstance, EditorFrameProps } from '../types';
import { Document } from '../persistence';
import {
createMockDatasource,
createMockVisualization,
DatasourceMock,
makeDefaultServices,
mountWithProvider,
} from '../mocks';
import { visualizationMap, datasourceMap, makeDefaultServices, mountWithProvider } from '../mocks';
import { I18nProvider } from '@kbn/i18n/react';
import {
SavedObjectSaveModal,
@ -71,29 +65,6 @@ describe('Lens App', () => {
let defaultDoc: Document;
let defaultSavedObjectId: string;
const mockDatasource: DatasourceMock = createMockDatasource('testDatasource');
const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2');
const datasourceMap = {
testDatasource2: mockDatasource2,
testDatasource: mockDatasource,
};
const mockVisualization = {
...createMockVisualization(),
id: 'testVis',
visualizationTypes: [
{
icon: 'empty',
id: 'testVis',
label: 'TEST1',
groupLabel: 'testVisGroup',
},
],
};
const visualizationMap = {
testVis: mockVisualization,
};
function createMockFrame(): jest.Mocked<EditorFrameInstance> {
return {
EditorFrameContainer: jest.fn((props: EditorFrameProps) => <div />),
@ -1082,11 +1053,12 @@ describe('Lens App', () => {
});
it('updates the state if session id changes from the outside', async () => {
const services = makeDefaultServices(sessionIdSubject);
const sessionIdS = new Subject<string>();
const services = makeDefaultServices(sessionIdS);
const { lensStore } = await mountWith({ props: undefined, services });
act(() => {
sessionIdSubject.next('new-session-id');
sessionIdS.next('new-session-id');
});
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
@ -1181,7 +1153,7 @@ describe('Lens App', () => {
...defaultDoc,
state: {
...defaultDoc.state,
datasourceStates: { testDatasource: '' },
datasourceStates: { testDatasource: {} },
visualization: {},
},
},

View file

@ -29,13 +29,13 @@ import {
useLensDispatch,
LensAppState,
DispatchSetState,
selectSavedObjectFormat,
} from '../state_management';
import {
SaveModalContainer,
getLastKnownDocWithoutPinnedFilters,
runSaveLensVisualization,
} from './save_modal_container';
import { getSavedObjectFormat } from '../utils';
export type SaveProps = Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & {
returnToOrigin: boolean;
@ -79,11 +79,6 @@ export function App({
);
const {
datasourceStates,
visualization,
filters,
query,
activeDatasourceId,
persistedDoc,
isLinkedToOriginatingApp,
searchSessionId,
@ -91,52 +86,20 @@ export function App({
isSaveable,
} = useLensSelector((state) => state.lens);
const currentDoc = useLensSelector((state) =>
selectSavedObjectFormat(state, datasourceMap, visualizationMap)
);
// Used to show a popover that guides the user towards changing the date range when no data is available.
const [indicateNoData, setIndicateNoData] = useState(false);
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const [lastKnownDoc, setLastKnownDoc] = useState<Document | undefined>(undefined);
useEffect(() => {
const activeVisualization = visualization.activeId && visualizationMap[visualization.activeId];
const activeDatasource =
activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading
? datasourceMap[activeDatasourceId]
: undefined;
if (!activeDatasource || !activeVisualization || !visualization.state) {
return;
if (currentDoc) {
setLastKnownDoc(currentDoc);
}
setLastKnownDoc(
// todo: that should be redux store selector
getSavedObjectFormat({
activeDatasources: Object.keys(datasourceStates).reduce(
(acc, datasourceId) => ({
...acc,
[datasourceId]: datasourceMap[datasourceId],
}),
{}
),
datasourceStates,
visualization,
filters,
query,
title: persistedDoc?.title || '',
description: persistedDoc?.description,
persistedId: persistedDoc?.savedObjectId,
})
);
}, [
persistedDoc?.title,
persistedDoc?.description,
persistedDoc?.savedObjectId,
datasourceStates,
visualization,
filters,
query,
activeDatasourceId,
datasourceMap,
visualizationMap,
]);
}, [currentDoc]);
const showNoDataPopover = useCallback(() => {
setIndicateNoData(true);

View file

@ -22,12 +22,6 @@ function mockFrame(): FramePublicAPI {
return {
...createMockFramePublicAPI(),
datasourceLayers: {},
query: { query: '', language: 'lucene' },
dateRange: {
fromDate: 'now-7d',
toDate: 'now',
},
filters: [],
};
}

View file

@ -24,24 +24,32 @@ import {
updateDatasourceState,
updateVisualizationState,
setToggleFullscreen,
useLensSelector,
selectVisualization,
} from '../../../state_management';
export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) {
return props.activeVisualization && props.visualizationState ? (
<LayerPanels {...props} activeVisualization={props.activeVisualization} />
const visualization = useLensSelector(selectVisualization);
const activeVisualization = visualization.activeId
? props.visualizationMap[visualization.activeId]
: null;
return activeVisualization && visualization.state ? (
<LayerPanels {...props} activeVisualization={activeVisualization} />
) : null;
});
export function LayerPanels(
props: ConfigPanelWrapperProps & {
activeDatasourceId: string;
activeVisualization: Visualization;
}
) {
const { activeVisualization, visualizationState, activeDatasourceId, datasourceMap } = props;
const { activeVisualization, datasourceMap } = props;
const { activeDatasourceId, visualization } = useLensSelector((state) => state.lens);
const dispatchLens = useLensDispatch();
const layerIds = activeVisualization.getLayerIds(visualizationState);
const layerIds = activeVisualization.getLayerIds(visualization.state);
const {
setNextFocusedId: setNextFocusedLayerId,
removeRef: removeLayerRef,
@ -139,7 +147,7 @@ export function LayerPanels(
key={layerId}
layerId={layerId}
layerIndex={layerIndex}
visualizationState={visualizationState}
visualizationState={visualization.state}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateDatasourceAsync={updateDatasourceAsync}
@ -187,7 +195,7 @@ export function LayerPanels(
/>
) : null
)}
{activeVisualization.appendLayer && visualizationState && (
{activeVisualization.appendLayer && visualization.state && (
<EuiFlexItem grow={true} className="lnsConfigPanel__addLayerBtnWrapper">
<EuiToolTip
className="eui-fullWidth"
@ -220,7 +228,7 @@ export function LayerPanels(
activeVisualization,
generateId: () => id,
trackUiEvent,
activeDatasource: datasourceMap[activeDatasourceId],
activeDatasource: datasourceMap[activeDatasourceId!],
state,
}),
})

View file

@ -8,7 +8,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { EuiFormRow } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { Visualization } from '../../../types';
import { LayerPanel } from './layer_panel';
import { ChildDragDropProvider, DragDrop } from '../../../drag_drop';
@ -19,6 +18,7 @@ import {
createMockFramePublicAPI,
createMockDatasource,
DatasourceMock,
mountWithProvider,
} from '../../../mocks';
jest.mock('../../../id_generator');
@ -65,15 +65,8 @@ describe('LayerPanel', () => {
return {
layerId: 'first',
activeVisualization: mockVisualization,
activeDatasourceId: 'ds1',
datasourceMap: {
ds1: mockDatasource,
},
datasourceStates: {
ds1: {
isLoading: false,
state: 'state',
},
testDatasource: mockDatasource,
},
visualizationState: 'state',
updateVisualization: jest.fn(),
@ -120,45 +113,49 @@ describe('LayerPanel', () => {
};
mockVisualization.getLayerIds.mockReturnValue(['first']);
mockDatasource = createMockDatasource('ds1');
mockDatasource = createMockDatasource('testDatasource');
});
describe('layer reset and remove', () => {
it('should show the reset button when single layer', () => {
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
expect(component.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
it('should show the reset button when single layer', async () => {
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
'Reset layer'
);
});
it('should show the delete button when multiple layers', () => {
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} isOnlyLayer={false} />);
expect(component.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
it('should show the delete button when multiple layers', async () => {
const { instance } = await mountWithProvider(
<LayerPanel {...getDefaultProps()} isOnlyLayer={false} />
);
expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
'Delete layer'
);
});
it('should show to reset visualization for visualizations only allowing a single layer', () => {
it('should show to reset visualization for visualizations only allowing a single layer', async () => {
const layerPanelAttributes = getDefaultProps();
delete layerPanelAttributes.activeVisualization.removeLayer;
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
expect(component.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
expect(instance.find('[data-test-subj="lnsLayerRemove"]').first().text()).toContain(
'Reset visualization'
);
});
it('should call the clear callback', () => {
it('should call the clear callback', async () => {
const cb = jest.fn();
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} onRemoveLayer={cb} />);
const { instance } = await mountWithProvider(
<LayerPanel {...getDefaultProps()} onRemoveLayer={cb} />
);
act(() => {
component.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerRemove"]').first().simulate('click');
});
expect(cb).toHaveBeenCalled();
});
});
describe('single group', () => {
it('should render the non-editable state', () => {
it('should render the non-editable state', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -172,12 +169,12 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
const group = instance.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('should render the group with a way to add a new column', () => {
it('should render the group with a way to add a new column', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -191,12 +188,12 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
const group = instance.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
it('should render the required warning when only one group is configured', () => {
it('should render the required warning when only one group is configured', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -219,16 +216,16 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
const group = component
const group = instance
.find(EuiFormRow)
.findWhere((e) => e.prop('error')?.props?.children === 'Required dimension');
expect(group).toHaveLength(1);
});
it('should render the datasource and visualization panels inside the dimension container', () => {
it('should render the datasource and visualization panels inside the dimension container', async () => {
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
@ -244,18 +241,18 @@ describe('LayerPanel', () => {
});
mockVisualization.renderDimensionEditor = jest.fn();
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
instance.update();
const group = component.find('DimensionContainer').first();
const group = instance.find('DimensionContainer').first();
const panel: React.ReactElement = group.prop('panel');
expect(panel.props.children).toHaveLength(2);
});
it('should not update the visualization if the datasource is incomplete', () => {
it('should not update the visualization if the datasource is incomplete', async () => {
(generateId as jest.Mock).mockReturnValue(`newid`);
const updateAll = jest.fn();
const updateDatasourceAsync = jest.fn();
@ -273,7 +270,7 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<LayerPanel
{...getDefaultProps()}
updateDatasourceAsync={updateDatasourceAsync}
@ -282,9 +279,9 @@ describe('LayerPanel', () => {
);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
instance.update();
expect(mockDatasource.renderDimensionEditor).toHaveBeenCalledWith(
expect.any(Element),
@ -319,7 +316,7 @@ describe('LayerPanel', () => {
expect(updateAll).toHaveBeenCalled();
});
it('should remove the dimension when the datasource marks it as removed', () => {
it('should remove the dimension when the datasource marks it as removed', async () => {
const updateAll = jest.fn();
const updateDatasource = jest.fn();
@ -336,38 +333,42 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<LayerPanel
{...getDefaultProps()}
datasourceStates={{
ds1: {
isLoading: false,
state: {
layers: [
{
indexPatternId: '1',
columns: {
y: {
operationType: 'moving_average',
references: ['ref'],
},
},
columnOrder: ['y'],
incompleteColumns: {},
},
],
},
},
}}
updateDatasource={updateDatasource}
updateAll={updateAll}
/>
/>,
{
preloadedState: {
datasourceStates: {
testDatasource: {
isLoading: false,
state: {
layers: [
{
indexPatternId: '1',
columns: {
y: {
operationType: 'moving_average',
references: ['ref'],
},
},
columnOrder: ['y'],
incompleteColumns: {},
},
],
},
},
},
},
}
);
act(() => {
component.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
});
component.update();
instance.update();
expect(mockDatasource.renderDimensionEditor).toHaveBeenCalledWith(
expect.any(Element),
@ -399,7 +400,7 @@ describe('LayerPanel', () => {
);
});
it('should keep the DimensionContainer open when configuring a new dimension', () => {
it('should keep the DimensionContainer open when configuring a new dimension', async () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the container
@ -436,13 +437,13 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
instance.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(true);
expect(instance.find('EuiFlyoutHeader').exists()).toBe(true);
const lastArgs =
mockDatasource.renderDimensionEditor.mock.calls[
@ -459,7 +460,7 @@ describe('LayerPanel', () => {
expect(mockVisualization.renderDimensionEditor).toHaveBeenCalled();
});
it('should close the DimensionContainer when the active visualization changes', () => {
it('should close the DimensionContainer when the active visualization changes', async () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the container
@ -495,21 +496,21 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const { instance } = await mountWithProvider(<LayerPanel {...getDefaultProps()} />);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
instance.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(true);
instance.update();
expect(instance.find('EuiFlyoutHeader').exists()).toBe(true);
act(() => {
component.setProps({ activeVisualization: mockVisualization2 });
instance.setProps({ activeVisualization: mockVisualization2 });
});
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(false);
instance.update();
expect(instance.find('EuiFlyoutHeader').exists()).toBe(false);
});
it('should only update the state on close when needed', () => {
it('should only update the state on close when needed', async () => {
const updateDatasource = jest.fn();
mockVisualization.getConfiguration.mockReturnValue({
groups: [
@ -524,37 +525,37 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<LayerPanel {...getDefaultProps()} updateDatasource={updateDatasource} />
);
// Close without a state update
mockDatasource.updateStateOnCloseDimension = jest.fn();
component.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
act(() => {
(component.find('DimensionContainer').first().prop('handleClose') as () => void)();
(instance.find('DimensionContainer').first().prop('handleClose') as () => void)();
});
component.update();
instance.update();
expect(mockDatasource.updateStateOnCloseDimension).toHaveBeenCalled();
expect(updateDatasource).not.toHaveBeenCalled();
// Close with a state update
mockDatasource.updateStateOnCloseDimension = jest.fn().mockReturnValue({ newState: true });
component.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
instance.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').first().simulate('click');
act(() => {
(component.find('DimensionContainer').first().prop('handleClose') as () => void)();
(instance.find('DimensionContainer').first().prop('handleClose') as () => void)();
});
component.update();
instance.update();
expect(mockDatasource.updateStateOnCloseDimension).toHaveBeenCalled();
expect(updateDatasource).toHaveBeenCalledWith('ds1', { newState: true });
expect(updateDatasource).toHaveBeenCalledWith('testDatasource', { newState: true });
});
});
// This test is more like an integration test, since the layer panel owns all
// the coordination between drag and drop
describe('drag and drop behavior', () => {
it('should determine if the datasource supports dropping of a field onto empty dimension', () => {
it('should determine if the datasource supports dropping of a field onto empty dimension', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -584,7 +585,7 @@ describe('LayerPanel', () => {
},
};
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
@ -596,7 +597,7 @@ describe('LayerPanel', () => {
})
);
const dragDropElement = component
const dragDropElement = instance
.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop')
.first();
@ -610,7 +611,7 @@ describe('LayerPanel', () => {
);
});
it('should determine if the datasource supports dropping of a field onto a pre-filled dimension', () => {
it('should determine if the datasource supports dropping of a field onto a pre-filled dimension', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -639,7 +640,7 @@ describe('LayerPanel', () => {
},
};
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
@ -650,10 +651,10 @@ describe('LayerPanel', () => {
);
expect(
component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType')
instance.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType')
).toEqual(undefined);
const dragDropElement = component
const dragDropElement = instance
.find('[data-test-subj="lnsGroup"] DragDrop')
.first()
.find('.lnsLayerPanel__dimension');
@ -664,7 +665,7 @@ describe('LayerPanel', () => {
expect(mockDatasource.onDrop).not.toHaveBeenCalled();
});
it('should allow drag to move between groups', () => {
it('should allow drag to move between groups', async () => {
(generateId as jest.Mock).mockReturnValue(`newid`);
mockVisualization.getConfiguration.mockReturnValue({
@ -705,7 +706,7 @@ describe('LayerPanel', () => {
},
};
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
@ -719,7 +720,7 @@ describe('LayerPanel', () => {
// Simulate drop on the pre-populated dimension
const dragDropElement = component
const dragDropElement = instance
.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop')
.at(0);
dragDropElement.simulate('dragOver');
@ -734,7 +735,7 @@ describe('LayerPanel', () => {
// Simulate drop on the empty dimension
const updatedDragDropElement = component
const updatedDragDropElement = instance
.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop')
.at(2);
@ -749,7 +750,7 @@ describe('LayerPanel', () => {
);
});
it('should reorder when dropping in the same group', () => {
it('should reorder when dropping in the same group', async () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -775,14 +776,15 @@ describe('LayerPanel', () => {
},
};
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>,
undefined,
{ attachTo: container }
);
act(() => {
component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder');
instance.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder');
});
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
@ -790,7 +792,7 @@ describe('LayerPanel', () => {
droppedItem: draggingOperation,
})
);
const secondButton = component
const secondButton = instance
.find(DragDrop)
.at(1)
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]')
@ -799,7 +801,7 @@ describe('LayerPanel', () => {
expect(focusedEl).toEqual(secondButton);
});
it('should copy when dropping on empty slot in the same group', () => {
it('should copy when dropping on empty slot in the same group', async () => {
(generateId as jest.Mock).mockReturnValue(`newid`);
mockVisualization.getConfiguration.mockReturnValue({
groups: [
@ -826,13 +828,13 @@ describe('LayerPanel', () => {
},
};
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
act(() => {
component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
instance.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
});
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
@ -843,7 +845,7 @@ describe('LayerPanel', () => {
);
});
it('should call onDrop and update visualization when replacing between compatible groups', () => {
it('should call onDrop and update visualization when replacing between compatible groups', async () => {
const mockVis = {
...mockVisualization,
removeDimension: jest.fn(),
@ -881,7 +883,7 @@ describe('LayerPanel', () => {
mockDatasource.onDrop.mockReturnValue({ deleted: 'a' });
const updateVisualization = jest.fn();
const component = mountWithIntl(
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel
{...getDefaultProps()}
@ -891,7 +893,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
act(() => {
component.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
});
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({

View file

@ -29,6 +29,12 @@ import { EmptyDimensionButton } from './buttons/empty_dimension_button';
import { DimensionButton } from './buttons/dimension_button';
import { DraggableDimensionButton } from './buttons/draggable_dimension_button';
import { useFocusUpdate } from './use_focus_update';
import {
useLensSelector,
selectIsFullscreenDatasource,
selectResolvedDateRange,
selectDatasourceStates,
} from '../../../state_management';
const initialActiveDimensionState = {
isNew: false,
@ -51,7 +57,6 @@ export function LayerPanel(
onRemoveLayer: () => void;
registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void;
toggleFullscreen: () => void;
isFullscreen: boolean;
}
) {
const [activeDimension, setActiveDimension] = useState<ActiveDimensionState>(
@ -69,12 +74,14 @@ export function LayerPanel(
updateVisualization,
updateDatasource,
toggleFullscreen,
isFullscreen,
updateAll,
updateDatasourceAsync,
visualizationState,
} = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
const dateRange = useLensSelector(selectResolvedDateRange);
const datasourceStates = useLensSelector(selectDatasourceStates);
const isFullscreen = useLensSelector(selectIsFullscreenDatasource);
useEffect(() => {
setActiveDimension(initialActiveDimensionState);
@ -90,12 +97,12 @@ export function LayerPanel(
layerId,
state: props.visualizationState,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
dateRange,
activeData: props.framePublicAPI.activeData,
};
const datasourceId = datasourcePublicAPI.datasourceId;
const layerDatasourceState = props.datasourceStates[datasourceId].state;
const layerDatasourceState = datasourceStates[datasourceId].state;
const layerDatasourceDropProps = useMemo(
() => ({
@ -113,8 +120,8 @@ export function LayerPanel(
const layerDatasourceConfigProps = {
...layerDatasourceDropProps,
frame: props.framePublicAPI,
dateRange: props.framePublicAPI.dateRange,
activeData: props.framePublicAPI.activeData,
dateRange,
};
const { groups } = useMemo(

View file

@ -11,39 +11,21 @@ import {
DatasourceDimensionEditorProps,
VisualizationDimensionGroupConfig,
DatasourceMap,
VisualizationMap,
} from '../../../types';
export interface ConfigPanelWrapperProps {
activeDatasourceId: string;
visualizationState: unknown;
activeVisualization: Visualization | null;
framePublicAPI: FramePublicAPI;
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
visualizationMap: VisualizationMap;
core: DatasourceDimensionEditorProps['core'];
isFullscreen: boolean;
}
export interface LayerPanelProps {
activeDatasourceId: string;
visualizationState: unknown;
datasourceMap: DatasourceMap;
activeVisualization: Visualization;
framePublicAPI: FramePublicAPI;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
core: DatasourceDimensionEditorProps['core'];
isFullscreen: boolean;
}
export interface LayerDatasourceDropProps {

View file

@ -10,7 +10,6 @@ import './data_panel_wrapper.scss';
import React, { useMemo, memo, useContext, useState, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { createSelector } from '@reduxjs/toolkit';
import { NativeRenderer } from '../../native_renderer';
import { DragContext, DragDropIdentifier } from '../../drag_drop';
import { StateSetter, DatasourceDataPanelProps, DatasourceMap } from '../../types';
@ -19,17 +18,16 @@ import {
switchDatasource,
useLensDispatch,
updateDatasourceState,
LensState,
useLensSelector,
setState,
selectExecutionContext,
selectActiveDatasourceId,
selectDatasourceStates,
} from '../../state_management';
import { initializeDatasources } from './state_helpers';
interface DataPanelWrapperProps {
datasourceState: unknown;
datasourceMap: DatasourceMap;
activeDatasource: string | null;
datasourceIsLoading: boolean;
showNoDataPopover: () => void;
core: DatasourceDataPanelProps['core'];
dropOntoWorkspace: (field: DragDropIdentifier) => void;
@ -37,35 +35,27 @@ interface DataPanelWrapperProps {
plugins: { uiActions: UiActionsStart };
}
const getExternals = createSelector(
(state: LensState) => state.lens,
({ resolvedDateRange, query, filters, datasourceStates, activeDatasourceId }) => ({
dateRange: resolvedDateRange,
query,
filters,
datasourceStates,
activeDatasourceId,
})
);
export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
const { activeDatasource } = props;
const externalContext = useLensSelector(selectExecutionContext);
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const datasourceStates = useLensSelector(selectDatasourceStates);
const datasourceIsLoading = activeDatasourceId
? datasourceStates[activeDatasourceId].isLoading
: true;
const { filters, query, dateRange, datasourceStates, activeDatasourceId } = useLensSelector(
getExternals
);
const dispatchLens = useLensDispatch();
const setDatasourceState: StateSetter<unknown> = useMemo(() => {
return (updater) => {
dispatchLens(
updateDatasourceState({
updater,
datasourceId: activeDatasource!,
datasourceId: activeDatasourceId!,
clearStagedPreview: true,
})
);
};
}, [activeDatasource, dispatchLens]);
}, [activeDatasourceId, dispatchLens]);
useEffect(() => {
if (activeDatasourceId && datasourceStates[activeDatasourceId].state === null) {
@ -88,13 +78,11 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
}, [datasourceStates, activeDatasourceId, props.datasourceMap, dispatchLens]);
const datasourceProps: DatasourceDataPanelProps = {
...externalContext,
dragDropContext: useContext(DragContext),
state: props.datasourceState,
state: activeDatasourceId ? datasourceStates[activeDatasourceId].state : null,
setState: setDatasourceState,
core: props.core,
filters,
query,
dateRange,
showNoDataPopover: props.showNoDataPopover,
dropOntoWorkspace: props.dropOntoWorkspace,
hasSuggestionForField: props.hasSuggestionForField,
@ -135,7 +123,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
<EuiContextMenuItem
key={datasourceId}
data-test-subj={`datasource-switch-${datasourceId}`}
icon={props.activeDatasource === datasourceId ? 'check' : 'empty'}
icon={activeDatasourceId === datasourceId ? 'check' : 'empty'}
onClick={() => {
setDatasourceSwitcher(false);
dispatchLens(switchDatasource({ newDatasourceId: datasourceId }));
@ -147,10 +135,10 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {
/>
</EuiPopover>
)}
{props.activeDatasource && !props.datasourceIsLoading && (
{activeDatasourceId && !datasourceIsLoading && (
<NativeRenderer
className="lnsDataPanelWrapper"
render={props.datasourceMap[props.activeDatasource].renderDataPanel}
render={props.datasourceMap[activeDatasourceId].renderDataPanel}
nativeProps={datasourceProps}
/>
)}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { ReactElement } from 'react';
import React from 'react';
import { ReactWrapper } from 'enzyme';
// Tests are executed in a jsdom environment who does not have sizing methods,
@ -45,8 +45,7 @@ import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/pu
import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks';
import { expressionsPluginMock } from '../../../../../../src/plugins/expressions/public/mocks';
import { mockDataPlugin, mountWithProvider } from '../../mocks';
import { setState, setToggleFullscreen } from '../../state_management';
import { FrameLayout } from './frame_layout';
import { setState } from '../../state_management';
function generateSuggestion(state = {}): DatasourceSuggestion {
return {
@ -213,7 +212,7 @@ describe('editor_frame', () => {
const props = {
...getDefaultProps(),
visualizationMap: {
testVis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
},
datasourceMap: {
testDatasource: {
@ -246,7 +245,7 @@ describe('editor_frame', () => {
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
| vis"
| testVis"
`);
});
@ -263,7 +262,7 @@ describe('editor_frame', () => {
const props = {
...getDefaultProps(),
visualizationMap: {
testVis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
},
datasourceMap: {
testDatasource: {
@ -368,7 +367,7 @@ describe('editor_frame', () => {
},
Object {
"arguments": Object {},
"function": "vis",
"function": "testVis",
"type": "function",
},
],
@ -1100,45 +1099,5 @@ describe('editor_frame', () => {
})
);
});
it('should avoid completely to compute suggestion when in fullscreen mode', async () => {
const props = {
...getDefaultProps(),
initialContext: {
indexPatternId: '1',
fieldName: 'test',
},
visualizationMap: {
testVis: mockVisualization,
},
datasourceMap: {
testDatasource: mockDatasource,
testDatasource2: mockDatasource2,
},
ExpressionRenderer: expressionRendererMock,
};
const { instance: el, lensStore } = await mountWithProvider(<EditorFrame {...props} />, {
data: props.plugins.data,
});
instance = el;
expect(
instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement
).not.toBeUndefined();
lensStore.dispatch(setToggleFullscreen());
instance.update();
expect(instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement).toBe(false);
lensStore.dispatch(setToggleFullscreen());
instance.update();
expect(
instance.find(FrameLayout).prop('suggestionsPanel') as ReactElement
).not.toBeUndefined();
});
});
});

View file

@ -5,21 +5,28 @@
* 2.0.
*/
import React, { useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef } from 'react';
import { CoreStart } from 'kibana/public';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../types';
import { DataPanelWrapper } from './data_panel_wrapper';
import { ConfigPanelWrapper } from './config_panel';
import { FrameLayout } from './frame_layout';
import { SuggestionPanel } from './suggestion_panel';
import { SuggestionPanelWrapper } from './suggestion_panel';
import { WorkspacePanel } from './workspace_panel';
import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop';
import { EditorFrameStartPlugins } from '../service';
import { createDatasourceLayers } from './state_helpers';
import { getTopSuggestionForField, switchToSuggestion, Suggestion } from './suggestion_helpers';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { useLensSelector, useLensDispatch } from '../../state_management';
import {
useLensSelector,
useLensDispatch,
selectAreDatasourcesLoaded,
selectFramePublicAPI,
selectActiveDatasourceId,
selectDatasourceStates,
selectVisualization,
} from '../../state_management';
export interface EditorFrameProps {
datasourceMap: DatasourceMap;
@ -31,58 +38,27 @@ export interface EditorFrameProps {
}
export function EditorFrame(props: EditorFrameProps) {
const {
activeData,
resolvedDateRange: dateRange,
query,
filters,
searchSessionId,
activeDatasourceId,
visualization,
datasourceStates,
stagedPreview,
isFullscreenDatasource,
} = useLensSelector((state) => state.lens);
const { datasourceMap, visualizationMap } = props;
const dispatchLens = useLensDispatch();
const allLoaded = Object.values(datasourceStates).every(({ isLoading }) => isLoading === false);
const datasourceLayers = React.useMemo(
() => createDatasourceLayers(props.datasourceMap, datasourceStates),
[props.datasourceMap, datasourceStates]
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const datasourceStates = useLensSelector(selectDatasourceStates);
const visualization = useLensSelector(selectVisualization);
const allLoaded = useLensSelector(selectAreDatasourcesLoaded);
const framePublicAPI: FramePublicAPI = useLensSelector((state) =>
selectFramePublicAPI(state, datasourceMap)
);
const framePublicAPI: FramePublicAPI = useMemo(
() => ({
datasourceLayers,
activeData,
dateRange,
query,
filters,
searchSessionId,
}),
[activeData, datasourceLayers, dateRange, query, filters, searchSessionId]
);
// Using a ref to prevent rerenders in the child components while keeping the latest state
const getSuggestionForField = useRef<(field: DragDropIdentifier) => Suggestion | undefined>();
getSuggestionForField.current = (field: DragDropIdentifier) => {
const activeVisualizationId = visualization.activeId;
const visualizationState = visualization.state;
const { visualizationMap, datasourceMap } = props;
if (!field || !activeDatasourceId) {
return;
}
return getTopSuggestionForField(
datasourceLayers,
activeVisualizationId,
visualizationMap,
visualizationState,
datasourceMap[activeDatasourceId],
framePublicAPI.datasourceLayers,
visualization,
datasourceStates,
visualizationMap,
datasourceMap[activeDatasourceId],
field
);
};
@ -106,18 +82,12 @@ export function EditorFrame(props: EditorFrameProps) {
return (
<RootDragDropProvider>
<FrameLayout
isFullscreen={Boolean(isFullscreenDatasource)}
dataPanel={
<DataPanelWrapper
datasourceMap={props.datasourceMap}
core={props.core}
plugins={props.plugins}
datasourceMap={datasourceMap}
showNoDataPopover={props.showNoDataPopover}
activeDatasource={activeDatasourceId}
datasourceState={activeDatasourceId ? datasourceStates[activeDatasourceId].state : null}
datasourceIsLoading={
activeDatasourceId ? datasourceStates[activeDatasourceId].isLoading : true
}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
/>
@ -125,50 +95,33 @@ export function EditorFrame(props: EditorFrameProps) {
configPanel={
allLoaded && (
<ConfigPanelWrapper
activeVisualization={
visualization.activeId ? props.visualizationMap[visualization.activeId] : null
}
activeDatasourceId={activeDatasourceId!}
datasourceMap={props.datasourceMap}
datasourceStates={datasourceStates}
visualizationState={visualization.state}
framePublicAPI={framePublicAPI}
core={props.core}
isFullscreen={Boolean(isFullscreenDatasource)}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
framePublicAPI={framePublicAPI}
/>
)
}
workspacePanel={
allLoaded && (
<WorkspacePanel
activeDatasourceId={activeDatasourceId}
activeVisualizationId={visualization.activeId}
datasourceMap={props.datasourceMap}
datasourceStates={datasourceStates}
framePublicAPI={framePublicAPI}
visualizationState={visualization.state}
visualizationMap={props.visualizationMap}
isFullscreen={Boolean(isFullscreenDatasource)}
ExpressionRenderer={props.ExpressionRenderer}
core={props.core}
plugins={props.plugins}
ExpressionRenderer={props.ExpressionRenderer}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
framePublicAPI={framePublicAPI}
getSuggestionForField={getSuggestionForField.current}
/>
)
}
suggestionsPanel={
allLoaded &&
!isFullscreenDatasource && (
<SuggestionPanel
visualizationMap={props.visualizationMap}
datasourceMap={props.datasourceMap}
allLoaded && (
<SuggestionPanelWrapper
ExpressionRenderer={props.ExpressionRenderer}
stagedPreview={stagedPreview}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
frame={framePublicAPI}
activeVisualizationId={visualization.activeId}
activeDatasourceId={activeDatasourceId}
datasourceStates={datasourceStates}
visualizationState={visualization.state}
/>
)
}

View file

@ -6,18 +6,13 @@
*/
import { Ast, fromExpression, ExpressionFunctionAST } from '@kbn/interpreter/common';
import { DatasourceStates } from '../../state_management';
import { Visualization, DatasourcePublicAPI, DatasourceMap } from '../../types';
export function prependDatasourceExpression(
visualizationExpression: Ast | string | null,
datasourceMap: DatasourceMap,
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>
datasourceStates: DatasourceStates
): Ast | null {
const datasourceExpressions: Array<[string, Ast | string]> = [];
@ -81,13 +76,7 @@ export function buildExpression({
visualization: Visualization | null;
visualizationState: unknown;
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
datasourceStates: DatasourceStates;
datasourceLayers: Record<string, DatasourcePublicAPI>;
}): Ast | null {
if (visualization === null) {

View file

@ -11,20 +11,22 @@ import React from 'react';
import { EuiPage, EuiPageBody, EuiScreenReaderOnly } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import classNames from 'classnames';
import { useLensSelector, selectIsFullscreenDatasource } from '../../state_management';
export interface FrameLayoutProps {
dataPanel: React.ReactNode;
configPanel?: React.ReactNode;
suggestionsPanel?: React.ReactNode;
workspacePanel?: React.ReactNode;
isFullscreen?: boolean;
}
export function FrameLayout(props: FrameLayoutProps) {
const isFullscreen = useLensSelector(selectIsFullscreenDatasource);
return (
<EuiPage
className={classNames('lnsFrameLayout', {
'lnsFrameLayout-isFullscreen': props.isFullscreen,
'lnsFrameLayout-isFullscreen': isFullscreen,
})}
>
<EuiPageBody
@ -48,7 +50,7 @@ export function FrameLayout(props: FrameLayoutProps) {
<section
className={classNames('lnsFrameLayout__pageBody', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'lnsFrameLayout__pageBody-isFullscreen': props.isFullscreen,
'lnsFrameLayout__pageBody-isFullscreen': isFullscreen,
})}
aria-labelledby="workspaceId"
>
@ -65,7 +67,7 @@ export function FrameLayout(props: FrameLayoutProps) {
<section
className={classNames('lnsFrameLayout__sidebar lnsFrameLayout__sidebar--right', {
// eslint-disable-next-line @typescript-eslint/naming-convention
'lnsFrameLayout__sidebar-isFullscreen': props.isFullscreen,
'lnsFrameLayout__sidebar-isFullscreen': isFullscreen,
})}
aria-labelledby="configPanel"
>

View file

@ -28,15 +28,16 @@ import {
getMissingIndexPatterns,
getMissingVisualizationTypeError,
} from '../error_helper';
import { DatasourceStates } from '../../state_management';
export async function initializeDatasources(
datasourceMap: DatasourceMap,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
datasourceStates: DatasourceStates,
references?: SavedObjectReference[],
initialContext?: VisualizeFieldContext,
options?: InitializationOptions
) {
const states: Record<string, { isLoading: boolean; state: unknown }> = {};
const states: DatasourceStates = {};
await Promise.all(
Object.entries(datasourceMap).map(([datasourceId, datasource]) => {
if (datasourceStates[datasourceId]) {
@ -57,8 +58,8 @@ export async function initializeDatasources(
}
export const createDatasourceLayers = memoizeOne(function createDatasourceLayers(
datasourceMap: DatasourceMap,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>
datasourceStates: DatasourceStates,
datasourceMap: DatasourceMap
) {
const datasourceLayers: Record<string, DatasourcePublicAPI> = {};
Object.keys(datasourceMap)
@ -79,7 +80,7 @@ export const createDatasourceLayers = memoizeOne(function createDatasourceLayers
});
export async function persistedStateToExpression(
datasources: Record<string, Datasource>,
datasourceMap: DatasourceMap,
visualizations: VisualizationMap,
doc: Document
): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> {
@ -98,7 +99,7 @@ export async function persistedStateToExpression(
}
const visualization = visualizations[visualizationType!];
const datasourceStates = await initializeDatasources(
datasources,
datasourceMap,
Object.fromEntries(
Object.entries(persistedDatasourceStates).map(([id, state]) => [
id,
@ -110,7 +111,7 @@ export async function persistedStateToExpression(
{ isFullEditor: false }
);
const datasourceLayers = createDatasourceLayers(datasources, datasourceStates);
const datasourceLayers = createDatasourceLayers(datasourceStates, datasourceMap);
const datasourceId = getActiveDatasourceIdFromDoc(doc);
if (datasourceId == null) {
@ -121,7 +122,7 @@ export async function persistedStateToExpression(
}
const indexPatternValidation = validateRequiredIndexPatterns(
datasources[datasourceId],
datasourceMap[datasourceId],
datasourceStates[datasourceId]
);
@ -133,7 +134,7 @@ export async function persistedStateToExpression(
}
const validationResult = validateDatasourceAndVisualization(
datasources[datasourceId],
datasourceMap[datasourceId],
datasourceStates[datasourceId].state,
visualization,
visualizationState,
@ -146,7 +147,7 @@ export async function persistedStateToExpression(
description,
visualization,
visualizationState,
datasourceMap: datasources,
datasourceMap,
datasourceStates,
datasourceLayers,
}),

View file

@ -9,6 +9,7 @@ import { getSuggestions, getTopSuggestionForField } from './suggestion_helpers';
import { createMockVisualization, createMockDatasource, DatasourceMock } from '../../mocks';
import { TableSuggestion, DatasourceSuggestion, Visualization } from '../../types';
import { PaletteOutput } from 'src/plugins/charts/public';
import { DatasourceStates } from '../../state_management';
const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSuggestion => ({
state,
@ -22,13 +23,7 @@ const generateSuggestion = (state = {}, layerId: string = 'first'): DatasourceSu
});
let datasourceMap: Record<string, DatasourceMock>;
let datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
let datasourceStates: DatasourceStates;
beforeEach(() => {
datasourceMap = {
@ -525,13 +520,10 @@ describe('suggestion helpers', () => {
getOperationForColumnId: jest.fn(),
},
},
'vis1',
{ activeId: 'vis1', state: {} },
{ mockindexpattern: { state: mockDatasourceState, isLoading: false } },
{ vis1: mockVisualization1 },
{},
datasourceMap.mock,
{
mockindexpattern: { state: mockDatasourceState, isLoading: false },
},
{ id: 'myfield', humanData: { label: 'myfieldLabel' } },
];
});
@ -558,7 +550,7 @@ describe('suggestion helpers', () => {
it('should return nothing if datasource does not produce suggestions', () => {
datasourceMap.mock.getDatasourceSuggestionsForField.mockReturnValue([]);
defaultParams[2] = {
defaultParams[3] = {
vis1: { ...mockVisualization1, getSuggestions: () => [] },
vis2: mockVisualization2,
};
@ -567,7 +559,7 @@ describe('suggestion helpers', () => {
});
it('should not consider suggestion from other visualization if there is data', () => {
defaultParams[2] = {
defaultParams[3] = {
vis1: { ...mockVisualization1, getSuggestions: () => [] },
vis2: mockVisualization2,
};
@ -593,7 +585,7 @@ describe('suggestion helpers', () => {
previewIcon: 'empty',
},
]);
defaultParams[2] = {
defaultParams[3] = {
vis1: mockVisualization1,
vis2: mockVisualization2,
vis3: mockVisualization3,

View file

@ -22,7 +22,13 @@ import {
VisualizationMap,
} from '../../types';
import { DragDropIdentifier } from '../../drag_drop';
import { LensDispatch, selectSuggestion, switchVisualization } from '../../state_management';
import {
LensDispatch,
selectSuggestion,
switchVisualization,
DatasourceStates,
VisualizationState,
} from '../../state_management';
export interface Suggestion {
visualizationId: string;
@ -60,13 +66,7 @@ export function getSuggestions({
mainPalette,
}: {
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
activeVisualizationId: string | null;
subVisualizationId?: string;
@ -143,13 +143,7 @@ export function getVisualizeFieldSuggestions({
visualizeTriggerFieldContext,
}: {
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
datasourceStates: DatasourceStates;
visualizationMap: VisualizationMap;
activeVisualizationId: string | null;
subVisualizationId?: string;
@ -228,11 +222,10 @@ export function switchToSuggestion(
export function getTopSuggestionForField(
datasourceLayers: Record<string, DatasourcePublicAPI>,
activeVisualizationId: string | null,
visualization: VisualizationState,
datasourceStates: DatasourceStates,
visualizationMap: Record<string, Visualization<unknown>>,
visualizationState: unknown,
datasource: Datasource,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
field: DragDropIdentifier
) {
const hasData = Object.values(datasourceLayers).some(
@ -240,20 +233,20 @@ export function getTopSuggestionForField(
);
const mainPalette =
activeVisualizationId && visualizationMap[activeVisualizationId]?.getMainPalette
? visualizationMap[activeVisualizationId].getMainPalette?.(visualizationState)
visualization.activeId && visualizationMap[visualization.activeId]?.getMainPalette
? visualizationMap[visualization.activeId].getMainPalette?.(visualization.state)
: undefined;
const suggestions = getSuggestions({
datasourceMap: { [datasource.id]: datasource },
datasourceStates,
visualizationMap:
hasData && activeVisualizationId
? { [activeVisualizationId]: visualizationMap[activeVisualizationId] }
hasData && visualization.activeId
? { [visualization.activeId]: visualizationMap[visualization.activeId] }
: visualizationMap,
activeVisualizationId,
visualizationState,
activeVisualizationId: visualization.activeId,
visualizationState: visualization.state,
field,
mainPalette,
});
return suggestions.find((s) => s.visualizationId === activeVisualizationId) || suggestions[0];
return suggestions.find((s) => s.visualizationId === visualization.activeId) || suggestions[0];
}

View file

@ -16,12 +16,12 @@ import {
} from '../../mocks';
import { act } from 'react-dom/test-utils';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
import { esFilters, IFieldType, IndexPattern } from '../../../../../../src/plugins/data/public';
import { SuggestionPanel, SuggestionPanelProps } from './suggestion_panel';
import { SuggestionPanel, SuggestionPanelProps, SuggestionPanelWrapper } from './suggestion_panel';
import { getSuggestions, Suggestion } from './suggestion_helpers';
import { EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui';
import { LensIconChartDatatable } from '../../assets/chart_datatable';
import { mountWithProvider } from '../../mocks';
import { LensAppState, PreviewState, setState, setToggleFullscreen } from '../../state_management';
jest.mock('./suggestion_helpers');
@ -38,6 +38,8 @@ describe('suggestion_panel', () => {
let defaultProps: SuggestionPanelProps;
let preloadedState: Partial<LensAppState>;
beforeEach(() => {
mockVisualization = createMockVisualization();
mockDatasource = createMockDatasource('a');
@ -49,7 +51,7 @@ describe('suggestion_panel', () => {
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion1State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion1',
keptLayerIds: ['a'],
},
@ -58,36 +60,58 @@ describe('suggestion_panel', () => {
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion2State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion2',
keptLayerIds: ['a'],
},
] as Suggestion[]);
defaultProps = {
activeDatasourceId: 'mock',
datasourceMap: {
mock: mockDatasource,
},
preloadedState = {
datasourceStates: {
mock: {
testDatasource: {
isLoading: false,
state: {},
state: '',
},
},
activeVisualizationId: 'vis',
visualization: {
activeId: 'testVis',
state: {},
},
activeDatasourceId: 'testDatasource',
};
defaultProps = {
datasourceMap: {
testDatasource: mockDatasource,
},
visualizationMap: {
vis: mockVisualization,
testVis: mockVisualization,
vis2: createMockVisualization(),
},
visualizationState: {},
ExpressionRenderer: expressionRendererMock,
frame: createMockFramePublicAPI(),
};
});
it('should avoid completely to render SuggestionPanel when in fullscreen mode', async () => {
const { instance, lensStore } = await mountWithProvider(
<SuggestionPanelWrapper {...defaultProps} />
);
expect(instance.find(SuggestionPanel).exists()).toBe(true);
lensStore.dispatch(setToggleFullscreen());
instance.update();
expect(instance.find(SuggestionPanel).exists()).toBe(false);
lensStore.dispatch(setToggleFullscreen());
instance.update();
expect(instance.find(SuggestionPanel).exists()).toBe(true);
});
it('should list passed in suggestions', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />, {
preloadedState,
});
expect(
instance
@ -98,62 +122,56 @@ describe('suggestion_panel', () => {
});
describe('uncommitted suggestions', () => {
let suggestionState: Pick<
SuggestionPanelProps,
'datasourceStates' | 'activeVisualizationId' | 'visualizationState'
>;
let stagedPreview: SuggestionPanelProps['stagedPreview'];
let suggestionState: Pick<LensAppState, 'datasourceStates' | 'visualization'>;
let stagedPreview: PreviewState;
beforeEach(() => {
suggestionState = {
datasourceStates: {
mock: {
testDatasource: {
isLoading: false,
state: {},
state: '',
},
},
activeVisualizationId: 'vis2',
visualizationState: {},
visualization: {
activeId: 'vis2',
state: {},
},
};
stagedPreview = {
datasourceStates: defaultProps.datasourceStates,
visualization: {
state: defaultProps.visualizationState,
activeId: defaultProps.activeVisualizationId,
},
datasourceStates: preloadedState.datasourceStates!,
visualization: preloadedState.visualization!,
};
});
it('should not update suggestions if current state is moved to staged preview', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance, lensStore } = await mountWithProvider(
<SuggestionPanel {...defaultProps} />,
{ preloadedState }
);
getSuggestionsMock.mockClear();
instance.setProps({
stagedPreview,
...suggestionState,
});
lensStore.dispatch(setState({ stagedPreview }));
instance.update();
expect(getSuggestionsMock).not.toHaveBeenCalled();
});
it('should update suggestions if staged preview is removed', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance, lensStore } = await mountWithProvider(
<SuggestionPanel {...defaultProps} />,
{ preloadedState }
);
getSuggestionsMock.mockClear();
instance.setProps({
stagedPreview,
...suggestionState,
});
lensStore.dispatch(setState({ stagedPreview, ...suggestionState }));
instance.update();
instance.setProps({
stagedPreview: undefined,
...suggestionState,
});
lensStore.dispatch(setState({ stagedPreview: undefined, ...suggestionState }));
instance.update();
expect(getSuggestionsMock).toHaveBeenCalledTimes(1);
});
it('should highlight currently active suggestion', async () => {
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />, {
preloadedState,
});
act(() => {
instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
});
@ -189,12 +207,14 @@ describe('suggestion_panel', () => {
});
it('should dispatch visualization switch action if suggestion is clicked', async () => {
const { instance, lensStore } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance, lensStore } = await mountWithProvider(<SuggestionPanel {...defaultProps} />, {
preloadedState,
});
act(() => {
instance.find('button[data-test-subj="lnsSuggestion"]').at(1).simulate('click');
});
instance.update();
// instance.update();
expect(lensStore.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
@ -203,7 +223,7 @@ describe('suggestion_panel', () => {
datasourceId: undefined,
datasourceState: {},
initialState: { suggestion1: true },
newVisualizationId: 'vis',
newVisualizationId: 'testVis',
},
})
);
@ -217,7 +237,7 @@ describe('suggestion_panel', () => {
previewIcon: LensIconChartDatatable,
score: 0.5,
visualizationState: suggestion1State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion1',
},
{
@ -225,7 +245,7 @@ describe('suggestion_panel', () => {
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion2State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion2',
previewExpression: 'test | expression',
},
@ -239,7 +259,9 @@ describe('suggestion_panel', () => {
mockDatasource.toExpression.mockReturnValue('datasource_expression');
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />);
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />, {
preloadedState,
});
expect(instance.find(EuiIcon)).toHaveLength(1);
expect(instance.find(EuiIcon).prop('type')).toEqual(LensIconChartDatatable);
@ -252,17 +274,20 @@ describe('suggestion_panel', () => {
indexPatterns: {},
};
mockDatasource.checkIntegrity.mockReturnValue(['a']);
const newProps = {
...defaultProps,
const newPreloadedState = {
...preloadedState,
datasourceStates: {
mock: {
...defaultProps.datasourceStates.mock,
testDatasource: {
...preloadedState.datasourceStates!.testDatasource,
state: missingIndexPatternsState,
},
},
};
const { instance } = await mountWithProvider(<SuggestionPanel {...newProps} />);
const { instance } = await mountWithProvider(<SuggestionPanel {...defaultProps} />, {
preloadedState: newPreloadedState,
});
expect(instance.html()).toEqual(null);
});
@ -274,7 +299,7 @@ describe('suggestion_panel', () => {
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion1State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion1',
},
{
@ -282,7 +307,7 @@ describe('suggestion_panel', () => {
previewIcon: 'empty',
score: 0.5,
visualizationState: suggestion2State,
visualizationId: 'vis',
visualizationId: 'testVis',
title: 'Suggestion2',
},
] as Suggestion[]);
@ -291,18 +316,7 @@ describe('suggestion_panel', () => {
(mockVisualization.toPreviewExpression as jest.Mock).mockReturnValueOnce('test | expression');
mockDatasource.toExpression.mockReturnValue('datasource_expression');
const indexPattern = ({ id: 'index1' } as unknown) as IndexPattern;
const field = ({ name: 'myfield' } as unknown) as IFieldType;
mountWithProvider(
<SuggestionPanel
{...defaultProps}
frame={{
...createMockFramePublicAPI(),
filters: [esFilters.buildExistsFilter(field, indexPattern)],
}}
/>
);
mountWithProvider(<SuggestionPanel {...defaultProps} frame={createMockFramePublicAPI()} />);
expect(expressionRendererMock).toHaveBeenCalledTimes(1);
const passedExpression = (expressionRendererMock as jest.Mock).mock.calls[0][0].expression;

View file

@ -42,30 +42,28 @@ import { prependDatasourceExpression } from './expression_helpers';
import { trackUiEvent, trackSuggestionEvent } from '../../lens_ui_telemetry';
import { getMissingIndexPattern, validateDatasourceAndVisualization } from './state_helpers';
import {
PreviewState,
rollbackSuggestion,
selectExecutionContextSearch,
submitSuggestion,
useLensDispatch,
useLensSelector,
selectCurrentVisualization,
selectCurrentDatasourceStates,
DatasourceStates,
selectIsFullscreenDatasource,
selectSearchSessionId,
selectActiveDatasourceId,
selectActiveData,
selectDatasourceStates,
} from '../../state_management';
const MAX_SUGGESTIONS_DISPLAYED = 5;
export interface SuggestionPanelProps {
activeDatasourceId: string | null;
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
activeVisualizationId: string | null;
visualizationMap: VisualizationMap;
visualizationState: unknown;
ExpressionRenderer: ReactExpressionRendererType;
frame: FramePublicAPI;
stagedPreview?: PreviewState;
}
const PreviewRenderer = ({
@ -173,128 +171,114 @@ const SuggestionPreview = ({
);
};
export const SuggestionPanelWrapper = (props: SuggestionPanelProps) => {
const isFullscreenDatasource = useLensSelector(selectIsFullscreenDatasource);
return isFullscreenDatasource ? null : <SuggestionPanel {...props} />;
};
export function SuggestionPanel({
activeDatasourceId,
datasourceMap,
datasourceStates,
activeVisualizationId,
visualizationMap,
visualizationState,
frame,
ExpressionRenderer: ExpressionRendererComponent,
stagedPreview,
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const currentDatasourceStates = stagedPreview ? stagedPreview.datasourceStates : datasourceStates;
const currentVisualizationState = stagedPreview
? stagedPreview.visualization.state
: visualizationState;
const currentVisualizationId = stagedPreview
? stagedPreview.visualization.activeId
: activeVisualizationId;
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const activeData = useLensSelector(selectActiveData);
const datasourceStates = useLensSelector(selectDatasourceStates);
const existsStagedPreview = useLensSelector((state) => Boolean(state.lens.stagedPreview));
const currentVisualization = useLensSelector(selectCurrentVisualization);
const currentDatasourceStates = useLensSelector(selectCurrentDatasourceStates);
const missingIndexPatterns = getMissingIndexPattern(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId ? datasourceStates[activeDatasourceId] : null
);
const { suggestions, currentStateExpression, currentStateError } = useMemo(
() => {
const newSuggestions = missingIndexPatterns.length
? []
: getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualizationId,
visualizationState: currentVisualizationState,
activeData: frame.activeData,
})
.filter(
({
hide,
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
!hide &&
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
),
}));
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state,
currentVisualizationId ? visualizationMap[currentVisualizationId] : null,
currentVisualizationState,
frame
);
const newStateExpression =
currentVisualizationState && currentVisualizationId && !validationErrors
? preparePreviewExpression(
{ visualizationState: currentVisualizationState },
visualizationMap[currentVisualizationId],
const { suggestions, currentStateExpression, currentStateError } = useMemo(() => {
const newSuggestions = missingIndexPatterns.length
? []
: getSuggestions({
datasourceMap,
datasourceStates: currentDatasourceStates,
visualizationMap,
activeVisualizationId: currentVisualization.activeId,
visualizationState: currentVisualization.state,
activeData,
})
.filter(
({
hide,
visualizationId,
visualizationState: suggestionVisualizationState,
datasourceState: suggestionDatasourceState,
datasourceId: suggetionDatasourceId,
}) => {
return (
!hide &&
validateDatasourceAndVisualization(
suggetionDatasourceId ? datasourceMap[suggetionDatasourceId] : null,
suggestionDatasourceState,
visualizationMap[visualizationId],
suggestionVisualizationState,
frame
) == null
);
}
)
.slice(0, MAX_SUGGESTIONS_DISPLAYED)
.map((suggestion) => ({
...suggestion,
previewExpression: preparePreviewExpression(
suggestion,
visualizationMap[suggestion.visualizationId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
),
}));
return {
suggestions: newSuggestions,
currentStateExpression: newStateExpression,
currentStateError: validationErrors,
};
},
const validationErrors = validateDatasourceAndVisualization(
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && currentDatasourceStates[activeDatasourceId]?.state,
currentVisualization.activeId ? visualizationMap[currentVisualization.activeId] : null,
currentVisualization.state,
frame
);
const newStateExpression =
currentVisualization.state && currentVisualization.activeId && !validationErrors
? preparePreviewExpression(
{ visualizationState: currentVisualization.state },
visualizationMap[currentVisualization.activeId],
datasourceMap,
currentDatasourceStates,
frame
)
: undefined;
return {
suggestions: newSuggestions,
currentStateExpression: newStateExpression,
currentStateError: validationErrors,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
[
currentDatasourceStates,
currentVisualizationState,
currentVisualizationId,
activeDatasourceId,
datasourceMap,
visualizationMap,
]
);
}, [
currentDatasourceStates,
currentVisualization.state,
currentVisualization.activeId,
activeDatasourceId,
datasourceMap,
visualizationMap,
]);
const context: ExecutionContextSearch = useMemo(
() => ({
query: frame.query,
timeRange: {
from: frame.dateRange.fromDate,
to: frame.dateRange.toDate,
},
filters: frame.filters,
}),
[frame.query, frame.dateRange.fromDate, frame.dateRange.toDate, frame.filters]
);
const context: ExecutionContextSearch = useLensSelector(selectExecutionContextSearch);
const searchSessionId = useLensSelector(selectSearchSessionId);
const contextRef = useRef<ExecutionContextSearch>(context);
contextRef.current = context;
const sessionIdRef = useRef<string>(frame.searchSessionId);
sessionIdRef.current = frame.searchSessionId;
const sessionIdRef = useRef<string>(searchSessionId);
sessionIdRef.current = searchSessionId;
const AutoRefreshExpressionRenderer = useMemo(() => {
return (props: ReactExpressionRendererProps) => (
@ -312,11 +296,11 @@ export function SuggestionPanel({
// if the staged preview is overwritten by a suggestion,
// reset the selected index to "current visualization" because
// we are not in transient suggestion state anymore
if (!stagedPreview && lastSelectedSuggestion !== -1) {
if (!existsStagedPreview && lastSelectedSuggestion !== -1) {
setLastSelectedSuggestion(-1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stagedPreview]);
}, [existsStagedPreview]);
if (!activeDatasourceId) {
return null;
@ -347,7 +331,7 @@ export function SuggestionPanel({
</h3>
</EuiTitle>
</EuiFlexItem>
{stagedPreview && (
{existsStagedPreview && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.lens.suggestion.refreshSuggestionTooltip', {
@ -373,14 +357,15 @@ export function SuggestionPanel({
</EuiFlexGroup>
<div className="lnsSuggestionPanel__suggestions">
{currentVisualizationId && (
{currentVisualization.activeId && (
<SuggestionPreview
preview={{
error: currentStateError != null,
expression: currentStateExpression,
icon:
visualizationMap[currentVisualizationId].getDescription(currentVisualizationState)
.icon || 'empty',
visualizationMap[currentVisualization.activeId].getDescription(
currentVisualization.state
).icon || 'empty',
title: i18n.translate('xpack.lens.suggestions.currentVisLabel', {
defaultMessage: 'Current visualization',
}),
@ -436,10 +421,7 @@ function getPreviewExpression(
return null;
}
const suggestionFrameApi: FramePublicAPI = {
...frame,
datasourceLayers: { ...frame.datasourceLayers },
};
const suggestionFrameApi: FramePublicAPI = { datasourceLayers: { ...frame.datasourceLayers } };
// use current frame api and patch apis for changed datasource layers
if (
@ -473,8 +455,8 @@ function getPreviewExpression(
function preparePreviewExpression(
visualizableState: VisualizableState,
visualization: Visualization,
datasourceMap: Record<string, Datasource<unknown, unknown>>,
datasourceStates: Record<string, { isLoading: boolean; state: unknown }>,
datasourceMap: DatasourceMap,
datasourceStates: DatasourceStates,
framePublicAPI: FramePublicAPI
) {
const suggestionDatasourceId = visualizableState.datasourceId;

View file

@ -11,6 +11,7 @@ import {
createMockVisualization,
createMockFramePublicAPI,
createMockDatasource,
mockDatasourceStates,
} from '../../../mocks';
import { mountWithProvider } from '../../../mocks';
@ -163,15 +164,6 @@ describe('chart_switch', () => {
};
}
function mockDatasourceStates() {
return {
testDatasource: {
state: {},
isLoading: false,
},
};
}
function showFlyout(instance: ReactWrapper) {
instance.find('[data-test-subj="lnsChartSwitchPopover"]').first().simulate('click');
}

View file

@ -35,6 +35,11 @@ import {
updateVisualizationState,
useLensDispatch,
useLensSelector,
VisualizationState,
DatasourceStates,
selectActiveDatasourceId,
selectVisualization,
selectDatasourceStates,
} from '../../../state_management';
import { generateId } from '../../../id_generator/id_generator';
@ -111,9 +116,9 @@ function getCurrentVisualizationId(
export const ChartSwitch = memo(function ChartSwitch(props: Props) {
const [flyoutOpen, setFlyoutOpen] = useState<boolean>(false);
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector((state) => state.lens.activeDatasourceId);
const visualization = useLensSelector((state) => state.lens.visualization);
const datasourceStates = useLensSelector((state) => state.lens.datasourceStates);
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const visualization = useLensSelector(selectVisualization);
const datasourceStates = useLensSelector(selectDatasourceStates);
function removeLayers(layerIds: string[]) {
const activeVisualization =
@ -498,11 +503,8 @@ export const ChartSwitch = memo(function ChartSwitch(props: Props) {
function getTopSuggestion(
props: Props,
visualizationId: string,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
visualization: {
activeId: string | null;
state: unknown;
},
datasourceStates: DatasourceStates,
visualization: VisualizationState,
newVisualization: Visualization<unknown>,
subVisualizationId?: string
): Suggestion | undefined {

View file

@ -33,6 +33,7 @@ import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/publ
import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks';
import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers';
import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable';
import { LensRootStore, setState } from '../../../state_management';
const defaultPermissions: Record<string, Record<string, boolean | Record<string, boolean>>> = {
navLinks: { management: true },
@ -49,12 +50,8 @@ function createCoreStartWithPermissions(newCapabilities = defaultPermissions) {
}
const defaultProps = {
activeDatasourceId: 'mock',
datasourceStates: {},
datasourceMap: {},
framePublicAPI: createMockFramePublicAPI(),
activeVisualizationId: 'vis',
visualizationState: {},
ExpressionRenderer: createExpressionRendererMock(),
core: createCoreStartWithPermissions(),
plugins: {
@ -62,7 +59,6 @@ const defaultProps = {
data: mockDataPlugin(),
},
getSuggestionForField: () => undefined,
isFullscreen: false,
toggleFullscreen: jest.fn(),
};
@ -84,7 +80,7 @@ describe('workspace_panel', () => {
uiActionsMock.getTrigger.mockReturnValue(trigger);
mockVisualization = createMockVisualization();
mockVisualization2 = createMockVisualization();
mockDatasource = createMockDatasource('a');
mockDatasource = createMockDatasource('testDatasource');
expressionRendererMock = createExpressionRendererMock();
});
@ -96,14 +92,16 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
activeVisualizationId={null}
visualizationMap={{
vis: mockVisualization,
testVis: mockVisualization,
}}
ExpressionRenderer={expressionRendererMock}
/>,
{ data: defaultProps.plugins.data }
{
data: defaultProps.plugins.data,
preloadedState: { visualization: { activeId: null, state: {} }, datasourceStates: {} },
}
);
instance = mounted.instance;
expect(instance.find('[data-test-subj="empty-workspace"]')).toHaveLength(2);
@ -115,11 +113,11 @@ describe('workspace_panel', () => {
<WorkspacePanel
{...defaultProps}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => null },
testVis: { ...mockVisualization, toExpression: () => null },
}}
/>,
{ data: defaultProps.plugins.data }
{ data: defaultProps.plugins.data, preloadedState: { datasourceStates: {} } }
);
instance = mounted.instance;
@ -132,11 +130,11 @@ describe('workspace_panel', () => {
<WorkspacePanel
{...defaultProps}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>,
{ data: defaultProps.plugins.data }
{ data: defaultProps.plugins.data, preloadedState: { datasourceStates: {} } }
);
instance = mounted.instance;
@ -155,18 +153,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -178,7 +170,7 @@ describe('workspace_panel', () => {
expect(instance.find(expressionRendererMock).prop('expression')).toMatchInlineSnapshot(`
"kibana
| lens_merge_tables layerIds=\\"first\\" tables={datasource}
| vis"
| testVis"
`);
});
@ -194,18 +186,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...props}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
plugins={{ ...props.plugins, uiActions: uiActionsMock }}
@ -235,18 +221,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -283,28 +263,32 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
mock2: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
mock2: mockDatasource2,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
{ data: defaultProps.plugins.data }
{
data: defaultProps.plugins.data,
preloadedState: {
datasourceStates: {
testDatasource: {
state: {},
isLoading: false,
},
mock2: {
state: {},
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
@ -364,18 +348,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -418,18 +396,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -470,23 +442,27 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>,
{ data: defaultProps.plugins.data }
{
data: defaultProps.plugins.data,
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
@ -504,20 +480,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
activeVisualizationId="vis"
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
// Use cannot navigate to the management page
core={createCoreStartWithPermissions({
@ -526,7 +494,18 @@ describe('workspace_panel', () => {
})}
/>,
{ data: defaultProps.plugins.data }
{
data: defaultProps.plugins.data,
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
@ -545,19 +524,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
// user can go to management, but indexPatterns management is not accessible
core={createCoreStartWithPermissions({
@ -566,7 +538,18 @@ describe('workspace_panel', () => {
})}
/>,
{ data: defaultProps.plugins.data }
{
data: defaultProps.plugins.data,
preloadedState: {
datasourceStates: {
testDatasource: {
// define a layer with an indexpattern not available
state: { layers: { indexPatternId: 'a' }, indexPatterns: {} },
isLoading: false,
},
},
},
}
);
instance = mounted.instance;
@ -588,18 +571,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>,
@ -617,7 +594,7 @@ describe('workspace_panel', () => {
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('vis');
mockVisualization.toExpression.mockReturnValue('testVis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@ -626,18 +603,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: mockVisualization,
testVis: mockVisualization,
}}
/>,
@ -657,7 +628,7 @@ describe('workspace_panel', () => {
mockVisualization.getErrorMessages.mockReturnValue([
{ shortMessage: 'Some error happened', longMessage: 'Some long description happened' },
]);
mockVisualization.toExpression.mockReturnValue('vis');
mockVisualization.toExpression.mockReturnValue('testVis');
const framePublicAPI = createMockFramePublicAPI();
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
@ -666,18 +637,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: mockVisualization,
testVis: mockVisualization,
}}
/>,
@ -703,18 +668,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
/>,
@ -738,18 +697,12 @@ describe('workspace_panel', () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -775,23 +728,17 @@ describe('workspace_panel', () => {
framePublicAPI.datasourceLayers = {
first: mockDatasource.publicAPIMock,
};
let lensStore: LensRootStore;
await act(async () => {
const mounted = await mountWithProvider(
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={framePublicAPI}
visualizationMap={{
vis: { ...mockVisualization, toExpression: () => 'vis' },
testVis: { ...mockVisualization, toExpression: () => 'testVis' },
}}
ExpressionRenderer={expressionRendererMock}
/>,
@ -799,6 +746,7 @@ describe('workspace_panel', () => {
{ data: defaultProps.plugins.data }
);
instance = mounted.instance;
lensStore = mounted.lensStore;
});
instance.update();
@ -809,7 +757,14 @@ describe('workspace_panel', () => {
return <span />;
});
instance.setProps({ visualizationState: {} });
lensStore!.dispatch(
setState({
visualization: {
activeId: 'testVis',
state: {},
},
})
);
instance.update();
expect(expressionRendererMock).toHaveBeenCalledTimes(2);
@ -843,18 +798,12 @@ describe('workspace_panel', () => {
>
<WorkspacePanel
{...defaultProps}
datasourceStates={{
mock: {
state: {},
isLoading: false,
},
}}
datasourceMap={{
mock: mockDatasource,
testDatasource: mockDatasource,
}}
framePublicAPI={frame}
visualizationMap={{
vis: mockVisualization,
testVis: mockVisualization,
vis2: mockVisualization2,
}}
getSuggestionForField={mockGetSuggestionForField}
@ -867,9 +816,9 @@ describe('workspace_panel', () => {
it('should immediately transition if exactly one suggestion is returned', async () => {
mockGetSuggestionForField.mockReturnValue({
visualizationId: 'vis',
visualizationId: 'testVis',
datasourceState: {},
datasourceId: 'mock',
datasourceId: 'testDatasource',
visualizationState: {},
});
const { lensStore } = await initComponent();
@ -879,19 +828,19 @@ describe('workspace_panel', () => {
expect(lensStore.dispatch).toHaveBeenCalledWith({
type: 'lens/switchVisualization',
payload: {
newVisualizationId: 'vis',
newVisualizationId: 'testVis',
initialState: {},
datasourceState: {},
datasourceId: 'mock',
datasourceId: 'testDatasource',
},
});
});
it('should allow to drop if there are suggestions', async () => {
mockGetSuggestionForField.mockReturnValue({
visualizationId: 'vis',
visualizationId: 'testVis',
datasourceState: {},
datasourceId: 'mock',
datasourceId: 'testDatasource',
visualizationState: {},
});
await initComponent();

View file

@ -40,6 +40,7 @@ import {
isLensEditEvent,
VisualizationMap,
DatasourceMap,
DatasourceFixAction,
} from '../../../types';
import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop';
import { Suggestion, switchToSuggestion } from '../suggestion_helpers';
@ -58,34 +59,30 @@ import {
updateVisualizationState,
updateDatasourceState,
setSaveable,
useLensSelector,
selectExecutionContext,
selectIsFullscreenDatasource,
selectVisualization,
selectDatasourceStates,
selectActiveDatasourceId,
selectSearchSessionId,
} from '../../../state_management';
export interface WorkspacePanelProps {
activeVisualizationId: string | null;
visualizationMap: VisualizationMap;
visualizationState: unknown;
activeDatasourceId: string | null;
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
state: unknown;
isLoading: boolean;
}
>;
framePublicAPI: FramePublicAPI;
ExpressionRenderer: ReactExpressionRendererType;
core: CoreStart;
plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart };
getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined;
isFullscreen: boolean;
}
interface WorkspaceState {
expressionBuildError?: Array<{
shortMessage: string;
longMessage: string;
fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise<unknown> };
fixAction?: DatasourceFixAction<unknown>;
}>;
expandError: boolean;
}
@ -120,29 +117,30 @@ export const WorkspacePanel = React.memo(function WorkspacePanel(props: Workspac
// Exported for testing purposes only.
export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
activeDatasourceId,
activeVisualizationId,
visualizationMap,
visualizationState,
datasourceMap,
datasourceStates,
framePublicAPI,
visualizationMap,
datasourceMap,
core,
plugins,
ExpressionRenderer: ExpressionRendererComponent,
suggestionForDraggedField,
isFullscreen,
}: Omit<WorkspacePanelProps, 'getSuggestionForField'> & {
suggestionForDraggedField: Suggestion | undefined;
}) {
const dispatchLens = useLensDispatch();
const isFullscreen = useLensSelector(selectIsFullscreenDatasource);
const visualization = useLensSelector(selectVisualization);
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
const datasourceStates = useLensSelector(selectDatasourceStates);
const { datasourceLayers } = framePublicAPI;
const [localState, setLocalState] = useState<WorkspaceState>({
expressionBuildError: undefined,
expandError: false,
});
const activeVisualization = activeVisualizationId
? visualizationMap[activeVisualizationId]
const activeVisualization = visualization.activeId
? visualizationMap[visualization.activeId]
: null;
const missingIndexPatterns = getMissingIndexPattern(
@ -175,64 +173,60 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
activeDatasourceId ? datasourceMap[activeDatasourceId] : null,
activeDatasourceId && datasourceStates[activeDatasourceId]?.state,
activeVisualization,
visualizationState,
visualization.state,
framePublicAPI
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[activeVisualization, visualizationState, activeDatasourceId, datasourceMap, datasourceStates]
[activeVisualization, visualization.state, activeDatasourceId, datasourceMap, datasourceStates]
);
const expression = useMemo(
() => {
if (!configurationValidationError?.length && !missingRefsErrors.length) {
try {
const ast = buildExpression({
visualization: activeVisualization,
visualizationState,
datasourceMap,
datasourceStates,
datasourceLayers: framePublicAPI.datasourceLayers,
});
const expression = useMemo(() => {
if (!configurationValidationError?.length && !missingRefsErrors.length) {
try {
const ast = buildExpression({
visualization: activeVisualization,
visualizationState: visualization.state,
datasourceMap,
datasourceStates,
datasourceLayers,
});
if (ast) {
// expression has to be turned into a string for dirty checking - if the ast is rebuilt,
// turning it into a string will make sure the expression renderer only re-renders if the
// expression actually changed.
return toExpression(ast);
} else {
return null;
}
} catch (e) {
const buildMessages = activeVisualization?.getErrorMessages(visualizationState);
const defaultMessage = {
shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', {
defaultMessage: 'An unexpected error occurred while preparing the chart',
}),
longMessage: e.toString(),
};
// Most likely an error in the expression provided by a datasource or visualization
setLocalState((s) => ({
...s,
expressionBuildError: buildMessages ?? [defaultMessage],
}));
if (ast) {
// expression has to be turned into a string for dirty checking - if the ast is rebuilt,
// turning it into a string will make sure the expression renderer only re-renders if the
// expression actually changed.
return toExpression(ast);
} else {
return null;
}
} catch (e) {
const buildMessages = activeVisualization?.getErrorMessages(visualization.state);
const defaultMessage = {
shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', {
defaultMessage: 'An unexpected error occurred while preparing the chart',
}),
longMessage: e.toString(),
};
// Most likely an error in the expression provided by a datasource or visualization
setLocalState((s) => ({
...s,
expressionBuildError: buildMessages ?? [defaultMessage],
}));
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
activeVisualization,
visualizationState,
datasourceMap,
datasourceStates,
framePublicAPI.dateRange,
framePublicAPI.query,
framePublicAPI.filters,
]
);
}
}, [
activeVisualization,
visualization.state,
datasourceMap,
datasourceStates,
datasourceLayers,
configurationValidationError?.length,
missingRefsErrors.length,
]);
const expressionExists = Boolean(expression);
const hasLoaded = Boolean(
activeVisualization && visualizationState && datasourceMap && datasourceStates
activeVisualization && visualization.state && datasourceMap && datasourceStates
);
useEffect(() => {
if (hasLoaded) {
@ -392,8 +386,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({
return (
<WorkspacePanelWrapper
framePublicAPI={framePublicAPI}
visualizationState={visualizationState}
visualizationId={activeVisualizationId}
visualizationState={visualization.state}
visualizationId={visualization.activeId}
datasourceStates={datasourceStates}
datasourceMap={datasourceMap}
visualizationMap={visualizationMap}
@ -424,7 +418,7 @@ export const VisualizationWrapper = ({
configurationValidationError?: Array<{
shortMessage: string;
longMessage: string;
fixAction?: { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise<unknown> };
fixAction?: DatasourceFixAction<unknown>;
}>;
missingRefsErrors?: Array<{ shortMessage: string; longMessage: string }>;
};
@ -432,22 +426,19 @@ export const VisualizationWrapper = ({
application: ApplicationStart;
activeDatasourceId: string | null;
}) => {
const context: ExecutionContextSearch = useMemo(
const context = useLensSelector(selectExecutionContext);
const searchContext: ExecutionContextSearch = useMemo(
() => ({
query: framePublicAPI.query,
query: context.query,
timeRange: {
from: framePublicAPI.dateRange.fromDate,
to: framePublicAPI.dateRange.toDate,
from: context.dateRange.fromDate,
to: context.dateRange.toDate,
},
filters: framePublicAPI.filters,
filters: context.filters,
}),
[
framePublicAPI.query,
framePublicAPI.dateRange.fromDate,
framePublicAPI.dateRange.toDate,
framePublicAPI.filters,
]
[context]
);
const searchSessionId = useLensSelector(selectSearchSessionId);
const dispatchLens = useLensDispatch();
@ -465,9 +456,7 @@ export const VisualizationWrapper = ({
| {
shortMessage: string;
longMessage: string;
fixAction?:
| { label: string; newState: (framePublicAPI: FramePublicAPI) => Promise<unknown> }
| undefined;
fixAction?: DatasourceFixAction<unknown>;
}
| undefined
) {
@ -480,7 +469,10 @@ export const VisualizationWrapper = ({
data-test-subj="errorFixAction"
onClick={async () => {
trackUiEvent('error_fix_action');
const newState = await validationError.fixAction?.newState(framePublicAPI);
const newState = await validationError.fixAction?.newState({
...framePublicAPI,
...context,
});
dispatchLens(
updateDatasourceState({
updater: newState,
@ -638,8 +630,8 @@ export const VisualizationWrapper = ({
className="lnsExpressionRenderer__component"
padding="m"
expression={expression!}
searchContext={context}
searchSessionId={framePublicAPI.searchSessionId}
searchContext={searchContext}
searchSessionId={searchSessionId}
onEvent={onEvent}
onData$={onData$}
renderMode="edit"

View file

@ -14,23 +14,22 @@ import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../../types'
import { NativeRenderer } from '../../../native_renderer';
import { ChartSwitch } from './chart_switch';
import { WarningsPopover } from './warnings_popover';
import { useLensDispatch, updateVisualizationState } from '../../../state_management';
import {
useLensDispatch,
updateVisualizationState,
DatasourceStates,
VisualizationState,
} from '../../../state_management';
import { WorkspaceTitle } from './title';
export interface WorkspacePanelWrapperProps {
children: React.ReactNode | React.ReactNode[];
framePublicAPI: FramePublicAPI;
visualizationState: unknown;
visualizationState: VisualizationState['state'];
visualizationMap: VisualizationMap;
visualizationId: string | null;
datasourceMap: DatasourceMap;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
datasourceStates: DatasourceStates;
isFullscreen: boolean;
}

View file

@ -50,7 +50,7 @@ import {
FormulaIndexPatternColumn,
} from './formula';
import { lastValueOperation, LastValueIndexPatternColumn } from './last_value';
import { FramePublicAPI, OperationMetadata } from '../../../types';
import { FrameDatasourceAPI, OperationMetadata } from '../../../types';
import type { BaseIndexPatternColumn, ReferenceBasedIndexPatternColumn } from './column_types';
import { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../types';
import { DateRange } from '../../../../common';
@ -280,7 +280,7 @@ interface BaseOperationDefinitionProps<C extends BaseIndexPatternColumn> {
label: string;
newState: (
core: CoreStart,
frame: FramePublicAPI,
frame: FrameDatasourceAPI,
layerId: string
) => Promise<IndexPatternLayer>;
};
@ -437,7 +437,7 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> {
label: string;
newState: (
core: CoreStart,
frame: FramePublicAPI,
frame: FrameDatasourceAPI,
layerId: string
) => Promise<IndexPatternLayer>;
};

View file

@ -22,7 +22,7 @@ import { FieldStatsResponse } from '../../../../../common';
import { AggFunctionsMapping, esQuery } from '../../../../../../../../src/plugins/data/public';
import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public';
import { updateColumnParam, isReferenced } from '../../layer_helpers';
import { DataType, FramePublicAPI } from '../../../../types';
import { DataType, FrameDatasourceAPI } from '../../../../types';
import { FiltersIndexPatternColumn, OperationDefinition, operationDefinitionMap } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { ValuesInput } from './values_input';
@ -77,7 +77,7 @@ function getDisallowedTermsMessage(
label: i18n.translate('xpack.lens.indexPattern.termsWithMultipleShiftsFixActionLabel', {
defaultMessage: 'Use filters',
}),
newState: async (core: CoreStart, frame: FramePublicAPI, layerId: string) => {
newState: async (core: CoreStart, frame: FrameDatasourceAPI, layerId: string) => {
const currentColumn = layer.columns[columnId] as TermsIndexPatternColumn;
const fieldName = currentColumn.sourceField;
const activeDataFieldNameMatch =

View file

@ -22,7 +22,7 @@ import { ValuesInput } from './values_input';
import type { TermsIndexPatternColumn } from '.';
import { termsOperation } from '../index';
import { IndexPattern, IndexPatternLayer } from '../../../types';
import { FramePublicAPI } from '../../../../types';
import { FrameDatasourceAPI } from '../../../../types';
const uiSettingsMock = {} as IUiSettingsClient;
@ -1110,7 +1110,7 @@ describe('terms', () => {
fromDate: '2020',
toDate: '2021',
},
} as unknown) as FramePublicAPI,
} as unknown) as FrameDatasourceAPI,
'first'
);
expect(newLayer.columns.col1).toEqual(

View file

@ -9,7 +9,8 @@ import { partition, mapValues, pickBy } from 'lodash';
import { CoreStart } from 'kibana/public';
import { Query } from 'src/plugins/data/common';
import type {
FramePublicAPI,
DatasourceFixAction,
FrameDatasourceAPI,
OperationMetadata,
VisualizationDimensionGroupConfig,
} from '../../types';
@ -1249,10 +1250,7 @@ export function getErrorMessages(
| string
| {
message: string;
fixAction?: {
label: string;
newState: (frame: FramePublicAPI) => Promise<IndexPatternPrivateState>;
};
fixAction?: DatasourceFixAction<IndexPatternPrivateState>;
}
>
| undefined {
@ -1284,7 +1282,7 @@ export function getErrorMessages(
fixAction: errorMessage.fixAction
? {
...errorMessage.fixAction,
newState: async (frame: FramePublicAPI) => ({
newState: async (frame: FrameDatasourceAPI) => ({
...state,
layers: {
...state.layers,
@ -1300,10 +1298,7 @@ export function getErrorMessages(
| string
| {
message: string;
fixAction?: {
label: string;
newState: (framePublicAPI: FramePublicAPI) => Promise<IndexPatternPrivateState>;
};
fixAction?: DatasourceFixAction<IndexPatternPrivateState>;
}
>;

View file

@ -39,7 +39,22 @@ import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/publ
import { makeConfigureStore, LensAppState, LensState } from './state_management/index';
import { getResolvedDateRange } from './utils';
import { presentationUtilPluginMock } from '../../../../src/plugins/presentation_util/public/mocks';
import { DatasourcePublicAPI, Datasource, Visualization, FramePublicAPI } from './types';
import {
DatasourcePublicAPI,
Datasource,
Visualization,
FramePublicAPI,
FrameDatasourceAPI,
} from './types';
export function mockDatasourceStates() {
return {
testDatasource: {
state: {},
isLoading: false,
},
};
}
export function createMockVisualization(): jest.Mocked<Visualization> {
return {
@ -83,9 +98,9 @@ export function createMockVisualization(): jest.Mocked<Visualization> {
};
}
const visualizationMap = {
vis: createMockVisualization(),
vis2: createMockVisualization(),
export const visualizationMap = {
testVis: createMockVisualization(),
testVis2: createMockVisualization(),
};
export type DatasourceMock = jest.Mocked<Datasource> & {
@ -134,7 +149,7 @@ export function createMockDatasource(id: string): DatasourceMock {
const mockDatasource: DatasourceMock = createMockDatasource('testDatasource');
const mockDatasource2: DatasourceMock = createMockDatasource('testDatasource2');
const datasourceMap = {
export const datasourceMap = {
testDatasource2: mockDatasource2,
testDatasource: mockDatasource,
};
@ -148,12 +163,18 @@ export function createExpressionRendererMock(): jest.Mock<
export type FrameMock = jest.Mocked<FramePublicAPI>;
export function createMockFramePublicAPI(): FrameMock {
return {
datasourceLayers: {},
};
}
export type FrameDatasourceMock = jest.Mocked<FrameDatasourceAPI>;
export function createMockFrameDatasourceAPI(): FrameDatasourceMock {
return {
datasourceLayers: {},
dateRange: { fromDate: 'now-7d', toDate: 'now' },
query: { query: '', language: 'lucene' },
filters: [],
searchSessionId: 'sessionId',
};
}
@ -393,12 +414,7 @@ export const defaultState = {
state: {},
activeId: 'testVis',
},
datasourceStates: {
testDatasource: {
isLoading: false,
state: '',
},
},
datasourceStates: mockDatasourceStates(),
};
export function makeLensStore({

View file

@ -14,6 +14,7 @@ import { optimizingMiddleware } from './optimizing_middleware';
import { LensState, LensStoreDeps } from './types';
import { initMiddleware } from './init_middleware';
export * from './types';
export * from './selectors';
export const reducer = {
lens: lensSlice.reducer,

View file

@ -0,0 +1,153 @@
/*
* 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 { createSelector } from '@reduxjs/toolkit';
import { SavedObjectReference } from 'kibana/server';
import { LensState } from './types';
import { extractFilterReferences } from '../persistence';
import { Datasource, DatasourceMap, VisualizationMap } from '../types';
import { createDatasourceLayers } from '../editor_frame_service/editor_frame';
export const selectPersistedDoc = (state: LensState) => state.lens.persistedDoc;
export const selectQuery = (state: LensState) => state.lens.query;
export const selectSearchSessionId = (state: LensState) => state.lens.searchSessionId;
export const selectFilters = (state: LensState) => state.lens.filters;
export const selectResolvedDateRange = (state: LensState) => state.lens.resolvedDateRange;
export const selectVisualization = (state: LensState) => state.lens.visualization;
export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview;
export const selectDatasourceStates = (state: LensState) => state.lens.datasourceStates;
export const selectActiveDatasourceId = (state: LensState) => state.lens.activeDatasourceId;
export const selectActiveData = (state: LensState) => state.lens.activeData;
export const selectIsFullscreenDatasource = (state: LensState) =>
Boolean(state.lens.isFullscreenDatasource);
export const selectExecutionContext = createSelector(
[selectQuery, selectFilters, selectResolvedDateRange],
(query, filters, dateRange) => ({
dateRange,
query,
filters,
})
);
export const selectExecutionContextSearch = createSelector(selectExecutionContext, (res) => ({
query: res.query,
timeRange: {
from: res.dateRange.fromDate,
to: res.dateRange.toDate,
},
filters: res.filters,
}));
const selectDatasourceMap = (state: LensState, datasourceMap: DatasourceMap) => datasourceMap;
const selectVisualizationMap = (
state: LensState,
datasourceMap: DatasourceMap,
visualizationMap: VisualizationMap
) => visualizationMap;
export const selectSavedObjectFormat = createSelector(
[
selectPersistedDoc,
selectVisualization,
selectDatasourceStates,
selectQuery,
selectFilters,
selectActiveDatasourceId,
selectDatasourceMap,
selectVisualizationMap,
],
(
persistedDoc,
visualization,
datasourceStates,
query,
filters,
activeDatasourceId,
datasourceMap,
visualizationMap
) => {
const activeVisualization =
visualization.state && visualization.activeId && visualizationMap[visualization.activeId];
const activeDatasource =
datasourceStates && activeDatasourceId && !datasourceStates[activeDatasourceId].isLoading
? datasourceMap[activeDatasourceId]
: undefined;
if (!activeDatasource || !activeVisualization) {
return;
}
const activeDatasources: Record<string, Datasource> = Object.keys(datasourceStates).reduce(
(acc, datasourceId) => ({
...acc,
[datasourceId]: datasourceMap[datasourceId],
}),
{}
);
const persistibleDatasourceStates: Record<string, unknown> = {};
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { state: persistableState, savedObjectReferences } = datasource.getPersistableState(
datasourceStates[id].state
);
persistibleDatasourceStates[id] = persistableState;
references.push(...savedObjectReferences);
});
const { persistableFilters, references: filterReferences } = extractFilterReferences(filters);
references.push(...filterReferences);
return {
savedObjectId: persistedDoc?.savedObjectId,
title: persistedDoc?.title || '',
description: persistedDoc?.description,
visualizationType: visualization.activeId,
type: 'lens',
references,
state: {
visualization: visualization.state,
query,
filters: persistableFilters,
datasourceStates: persistibleDatasourceStates,
},
};
}
);
export const selectCurrentVisualization = createSelector(
[selectVisualization, selectStagedPreview],
(visualization, stagedPreview) => (stagedPreview ? stagedPreview.visualization : visualization)
);
export const selectCurrentDatasourceStates = createSelector(
[selectDatasourceStates, selectStagedPreview],
(datasourceStates, stagedPreview) =>
stagedPreview ? stagedPreview.datasourceStates : datasourceStates
);
export const selectAreDatasourcesLoaded = createSelector(
selectDatasourceStates,
(datasourceStates) =>
Object.values(datasourceStates).every(({ isLoading }) => isLoading === false)
);
export const selectDatasourceLayers = createSelector(
[selectDatasourceStates, selectDatasourceMap],
(datasourceStates, datasourceMap) => createDatasourceLayers(datasourceStates, datasourceMap)
);
export const selectFramePublicAPI = createSelector(
[selectDatasourceStates, selectActiveData, selectDatasourceMap],
(datasourceStates, activeData, datasourceMap) => ({
datasourceLayers: createDatasourceLayers(datasourceStates, datasourceMap),
activeData,
})
);

View file

@ -10,7 +10,10 @@ import moment from 'moment';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { setState, LensDispatch } from '.';
import { LensAppState } from './types';
import { getResolvedDateRange, containsDynamicMath, TIME_LAG_PERCENTAGE_LIMIT } from '../utils';
import { getResolvedDateRange, containsDynamicMath } from '../utils';
const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
const TIME_LAG_MIN_LIMIT = 10000; // for a small timerange to avoid infinite data refresh timelag minimum is TIME_LAG_ABSOLUTE ms
/**
* checks if TIME_LAG_PERCENTAGE_LIMIT passed to renew searchSessionId
@ -48,8 +51,8 @@ function updateTimeRange(data: DataPublicPluginStart, dispatch: LensDispatch) {
// calculate lag of managed "now" for date math
const nowDiff = Date.now() - data.nowProvider.get().valueOf();
// if the lag is signifcant, start a new session to clear the cache
if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) {
// 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({
searchSessionId: data.search.session.start(),

View file

@ -15,12 +15,15 @@ import { DateRange } from '../../common';
import { LensAppServices } from '../app_plugin/types';
import { DatasourceMap, VisualizationMap } from '../types';
export interface VisualizationState {
activeId: string | null;
state: unknown;
}
export type DatasourceStates = Record<string, { state: unknown; isLoading: boolean }>;
export interface PreviewState {
visualization: {
activeId: string | null;
state: unknown;
};
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
visualization: VisualizationState;
datasourceStates: DatasourceStates;
}
export interface EditorFrameState extends PreviewState {
activeDatasourceId: string | null;

View file

@ -256,6 +256,11 @@ export interface Datasource<T = unknown, P = unknown> {
getWarningMessages?: (state: T, frame: FramePublicAPI) => React.ReactNode[] | undefined;
}
export interface DatasourceFixAction<T> {
label: string;
newState: (frame: FrameDatasourceAPI) => Promise<T>;
}
/**
* This is an API provided to visualizations by the frame, which calls the publicAPI on the datasource
*/
@ -516,10 +521,11 @@ export interface FramePublicAPI {
* If accessing, make sure to check whether expected columns actually exist.
*/
activeData?: Record<string, Datatable>;
}
export interface FrameDatasourceAPI extends FramePublicAPI {
dateRange: DateRange;
query: Query;
filters: Filter[];
searchSessionId: string;
}
/**

View file

@ -10,11 +10,10 @@ import { IndexPattern, IndexPatternsContract, TimefilterContract } from 'src/plu
import { IUiSettingsClient } from 'kibana/public';
import moment from 'moment-timezone';
import { SavedObjectReference } from 'kibana/public';
import { Filter, Query } from 'src/plugins/data/public';
import { uniq } from 'lodash';
import { Document } from './persistence/saved_object_store';
import { Datasource, DatasourceMap } from './types';
import { extractFilterReferences } from './persistence';
import { DatasourceStates } from './state_management';
export function getVisualizeGeoFieldMessage(fieldType: string) {
return i18n.translate('xpack.lens.visualizeGeoFieldMessage', {
@ -36,8 +35,6 @@ export function containsDynamicMath(dateMathString: string) {
return dateMathString.includes('now');
}
export const TIME_LAG_PERCENTAGE_LIMIT = 0.02;
export function getTimeZone(uiSettings: IUiSettingsClient) {
const configuredTimeZone = uiSettings.get('dateFormat:tz');
if (configuredTimeZone === 'Browser') {
@ -59,66 +56,12 @@ export const getInitialDatasourceId = (datasourceMap: DatasourceMap, doc?: Docum
return (doc && getActiveDatasourceIdFromDoc(doc)) || Object.keys(datasourceMap)[0] || null;
};
export interface GetIndexPatternsObjects {
activeDatasources: Record<string, Datasource>;
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
visualization: {
activeId: string | null;
state: unknown;
};
filters: Filter[];
query: Query;
title: string;
description?: string;
persistedId?: string;
}
export function getSavedObjectFormat({
activeDatasources,
datasourceStates,
visualization,
filters,
query,
title,
description,
persistedId,
}: GetIndexPatternsObjects): Document {
const persistibleDatasourceStates: Record<string, unknown> = {};
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {
const { state: persistableState, savedObjectReferences } = datasource.getPersistableState(
datasourceStates[id].state
);
persistibleDatasourceStates[id] = persistableState;
references.push(...savedObjectReferences);
});
const { persistableFilters, references: filterReferences } = extractFilterReferences(filters);
references.push(...filterReferences);
return {
savedObjectId: persistedId,
title,
description,
type: 'lens',
visualizationType: visualization.activeId,
state: {
datasourceStates: persistibleDatasourceStates,
visualization: visualization.state,
query,
filters: persistableFilters,
},
references,
};
}
export function getIndexPatternsIds({
activeDatasources,
datasourceStates,
}: {
activeDatasources: Record<string, Datasource>;
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>;
datasourceStates: DatasourceStates;
}): string[] {
const references: SavedObjectReference[] = [];
Object.entries(activeDatasources).forEach(([id, datasource]) => {